41 Commits

Author SHA1 Message Date
Tola Leng f5a985ff1e add new functionality and improve backend server structure
- Enhance server functionality and optimize backend code
- Add multi-database support (PostgreSQL, MySQL, SQLite)
2026-03-09 17:36:42 +07:00
Tola Leng 4b27b85038 Merge pull request #215 from MrSchneemann/feat/matrix-notification
feat: add Matrix chat notification support (#211)
2026-03-08 14:41:01 +07:00
MrSchneemann 463299fa73 feat(matrix): add HTML formatted messages with severity colors
Matrix notifications now send both a plain-text fallback and an
HTML formatted_body (org.matrix.custom.html) so Element and other
Matrix clients render rich, colored alerts:

- Down/failed/error → red (#E74C3C)
- Warning/expiring  → yellow (#F39C12)
- Maintenance/paused → orange (#E67E22)
- Up/resolved       → green (#2ECC71)
- Default/info      → blue (#3498DB)

Detail lines from the plain-text message are rendered as a <ul>
bullet list beneath the colored heading. Plain Body is preserved
for clients without HTML support.

Also includes resource type (CPU/DISK/etc.) in fallback server
alert messages to improve context.
2026-03-01 10:56:56 +01:00
MrSchneemann 91ceddc489 feat: add Matrix chat notification support (#211)
Implements native Matrix notification channel support, allowing users
to receive service alerts in any Matrix room via the Client-Server API.

Changes:
- server/service-operation/notification/matrix.go: new Matrix service
  implementing the NotificationService interface (PUT /_matrix/client/v3/...)
- server/service-operation/notification/types.go: add MatrixHomeserver,
  MatrixRoomID, MatrixAccessToken fields to AlertConfiguration
- server/service-operation/notification/manager.go: register MatrixService
- server/pb_migrations/1772200000_updated_alert_configurations_matrix.js:
  PocketBase migration adding matrix fields and notification_type value
- application: Matrix option in channel dialog with homeserver/room/token
  fields, tab filter, list display, type definitions and translations (en, km)
- application/public/upload/notification/matrix.png: Matrix channel icon
2026-03-01 10:48:01 +01:00
Tola Leng f2c338ced9 fix(security): enforce API authentication rules on collection (#214)
- Add authentication and authorization rules to prevent unauthorized access via REST API endpoints.
2026-02-27 00:17:45 +07:00
Tola Leng 9dbd267a22 docs: update progress and add implement multi-database support (PostgreSQL, MySQL, SQLite) 2026-02-15 16:16:58 +07:00
Tola Leng 2394243cb9 Merge pull request #207 from giriparus/impersonate-user
feat: implement user impersonation
2026-02-09 14:14:25 +07:00
giriparus bc38dba859 feat: implement user impersonation 2026-02-03 11:51:35 +05:30
Tola Leng 8f84fbf562 Update X platform URL 2026-01-31 15:38:44 +07:00
Tola Leng 6b8ee741b3 Fix server agent ID & token refresh when adding multiple agents (closes #165) 2026-01-24 21:16:10 +07:00
Tola Leng 370f613080 Fix hostname/IP validation to prevent whitespace input in the SSL and Create New Service form (closes #114) 2026-01-24 20:58:33 +07:00
Tola Leng 4f367b92da Fix responsive breakpoints and flex layout for sidebar menu 2026-01-13 01:28:57 +07:00
Tola Leng 6ab3be6fe4 Merge pull request #203 from aalhendi/fix/invalid-credentials-translation
fix: add missing invalidCredentials translation key
2026-01-05 03:19:43 +07:00
Abdulrazaq Alhendi 4221c4751a fix: add missing invalidCredentials translation key 2026-01-04 21:27:39 +03:00
Tola Leng a39b326f0e ci: disable frontend image build in build-frontend-image.yml 2025-12-02 00:45:44 +07:00
Tola Leng 84916f705a Refactor Menu Item to fix multi-line wrapping and spacing 2025-12-02 00:39:33 +07:00
Tola Leng e4e87abac5 Remove dockerfile of frontend from root directory 2025-11-28 17:01:41 +07:00
Tola Leng f528f6eaea reactor the Dockerfile.frontend to locate in Docker folder 2025-11-21 22:14:03 +07:00
Tola Leng b8966fe743 Merge pull request #193 from gnsworks/develop
Update : Japanese documents translation
2025-10-26 17:15:22 +07:00
gnsworks 2a4a6cea48 Update : Japanese documents translation 2025-10-25 21:15:22 +09:00
Tola Leng 3a3dc020fd refactor: update the Regional Monitoring Agent dialog structure with tabs and proper form layout. 2025-10-24 21:48:14 +07:00
Tola Leng ed5cff5026 refactor: Add the ENG translation string for the regional agent 2025-10-24 21:47:22 +07:00
Tola Leng bf45f896bd 🔐 docs(security): update version supported in Security terms 2025-10-23 02:23:19 +07:00
Tola Leng 00430b6cb7 refactor: update docker image for frontend development 2025-10-22 21:07:19 +07:00
Tola Leng feb65edee5 refactor: X url link in header 2025-10-22 21:06:32 +07:00
Tola Leng 04f125ac1e Merge pull request #188 from diptamahardhika/chore/dev-compose-persist-pb-data
feat(instance): multi-select & bulk delete; dev compose; persist pocketbase data volume in dev compose
2025-10-22 21:01:16 +07:00
Pradipta Mahardika 435694f694 chore(dev): persist pocketbase data volume in dev compose and docs 2025-10-16 23:43:31 +07:00
Tola Leng c33328f977 chore(project): Update development status and progress
- fix(server): regenerate Server Agent ID and Token after each agent creation without page refresh
2025-10-15 20:07:26 +07:00
Tola Leng 17e0d0a794 fix(server): regenerate Server Agent ID and Token after each agent creation without page refresh
- Previously, the Add Server Monitoring Agent dialog required a manual page refresh to show the new Server Agent ID and Server Token. This update automatically  generates and displays new credentials after each agent creation.
2025-10-13 14:57:51 +07:00
Tola Leng d408658b69 refactor: update database schema to align with UI date field changes 2025-10-11 20:57:09 +07:00
Tola Leng 682ce84ef7 docs: update contribution guidelines in CONTRIBUTING.md 2025-10-11 20:52:16 +07:00
Tola Leng 7392580db8 refactor: update database schema and migration files 2025-10-11 20:45:10 +07:00
Tola Leng ee5e66eb44 chore(project): Update development status and progress
- Fixed incorrect recovery time display in incident history
- Added a configuration option to delay downtime alert notifications
2025-10-11 20:24:47 +07:00
Tola Leng ed220b9a27 Merge pull request #178 from lyker189/develop
feat(i18n): localize frontend components and add missing translations
2025-10-02 22:31:03 +07:00
lyker189 b6ef4ff7a5 feat(i18n): localize frontend components and add missing translations
- Replaced hardcoded UI strings with translation keys across frontend files
- Added and updated English(en) and Khmer(km) translation files for settings, notifications, and server components
- Improved localization and multi-language support throughout the frontend
2025-10-02 05:10:11 +07:00
Tola Leng a4364936e3 style(sidebar): update menu items to use normal font weight 2025-10-01 22:59:21 +07:00
Tola Leng f5a8d989cc fix(ui): make "Rows per page" label display on a single line 2025-10-01 22:46:01 +07:00
Tola Leng 2e2df30b85 docs(readme): add JetBrains as a community supporter 2025-10-01 15:26:50 +07:00
Tola Leng 498b26dbd5 chore(project): Update development status and progress 2025-09-29 15:38:03 +07:00
Tola Leng 79fda8105f refactor: profile picture selection
- Removed profile picture selection UI from create/edit user forms. We will use the randome avatar instead.
2025-09-15 22:08:29 +07:00
Tola Leng 8d8e871b9f Fix: Enable/Disable notifications directly
- Make the status switch functional to toggle notifications directly from the notification channel list.
2025-09-15 21:04:10 +07:00
694 changed files with 180773 additions and 2986 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
server/
**/node_modules
**/dist
**/.next
**/.turbo
**/.cache
**/coverage
**/*.log
@@ -0,0 +1,59 @@
name: Build and Publish Frontend Image
on:
push:
branches: [ "develop", "feature/**" ]
paths:
- 'application/**'
- '.github/workflows/build-frontend-image.yml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/checkcle-frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: application
file: application/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
+4
View File
@@ -0,0 +1,4 @@
fileignoreconfig:
- filename: application/package-lock.json
checksum: 3253a06ef27a634ec2b8a8fe9c156288b9fcf57024623c7ddc3d321f2568e498
version: "1.0"
+2 -2
View File
@@ -79,7 +79,7 @@ Click "Fork" on [GitHub](https://github.com/operacle/checkcle) to create your ow
### 2. Clone Your Fork ### 2. Clone Your Fork
```bash ```bash
git clone --branch develop https://github.com/operacle/checkcle.git git clone https://github.com/yourusername/checkcle.git
cd checkcle cd checkcle
``` ```
@@ -116,7 +116,7 @@ go run main.go (you do not need to change the .env while it's the localhost conn
Click "Fork" on [GitHub](https://github.com/operacle/Distributed-Regional-Monitoring) to create your own copy. Click "Fork" on [GitHub](https://github.com/operacle/Distributed-Regional-Monitoring) to create your own copy.
#### 2. Clone Your Fork #### 2. Clone Your Fork
git clone --branch main https://github.com/operacle/Distributed-Regional-Monitoring.git git clone https://github.com/yourusername/Distributed-Regional-Monitoring.git
cd Distributed-Regional-Monitoring cd Distributed-Regional-Monitoring
#### 3. Install Go Service (make sure you have installed the Go Service) #### 3. Install Go Service (make sure you have installed the Go Service)
+8 -2
View File
@@ -60,7 +60,7 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
- Infrastructure Server Monitoring, Supports Linux (🐧 Debian, Ubuntu, CentOS, Red Hat, etc.) and Windows (Beta). And Servers metrics like CPU, RAM, disk usage, and network activity) with an one-line installation angent script. - Infrastructure Server Monitoring, Supports Linux (🐧 Debian, Ubuntu, CentOS, Red Hat, etc.) and Windows (Beta). And Servers metrics like CPU, RAM, disk usage, and network activity) with an one-line installation angent script.
- Schedule Maintenance & Incident Management - Schedule Maintenance & Incident Management
- Operational Status / Public Status Pages - Operational Status / Public Status Pages
- Notifications via email, Telegram, Discord, and Slack - Notifications via email, Telegram, Discord, Slack, Matrix, and more
- Reports & Analytics - Reports & Analytics
- Settings Panel (User Management, Data Retention, Multi-language, Themes (Dark & Light Mode), Notification and channels and alert templates). - Settings Panel (User Management, Data Retention, Multi-language, Themes (Dark & Light Mode), Notification and channels and alert templates).
@@ -151,6 +151,12 @@ If youre a tech company interested in supporting CheckCle, please contact the
height="50" height="50"
alt="DigitalOcean Logo" /> alt="DigitalOcean Logo" />
</a> </a>
</a>
<a href="https://www.jetbrains.com/">
<img src="https://cdn.checkcle.io/images/sponsor/jetbrains.png"
height="50"
alt="Jetbrains Logo" />
</a>
</div> </div>
--- ---
@@ -179,7 +185,7 @@ Here are some ways you can help improve CheckCle:
- Website: [checkcle.io](https://checkcle.io) - Website: [checkcle.io](https://checkcle.io)
- Documentation: [docs.checkcle.io](https://docs.checkcle.io) | Big thanks to [GitBook](https://github.com/gitbookio) for sponsoring the OSS site plan for CheckCle! - Documentation: [docs.checkcle.io](https://docs.checkcle.io) | Big thanks to [GitBook](https://github.com/gitbookio) for sponsoring the OSS site plan for CheckCle!
- Chat on Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX) - Chat on Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
- Follow us on X: [@checkcle_oss](https://x.com/checkcle_oss) - Follow us on X: [@asqrm_io](https://x.com/asqrm_io)
## 📜 License ## 📜 License
+1
View File
@@ -39,6 +39,7 @@ We support the latest stable release of `checkcle`. Security patches may also be
| Version | Supported | | Version | Supported |
|---------|-----------| |---------|-----------|
| `develop` (latest) | ✅ Yes |
| `main` (latest) | ✅ Yes | | `main` (latest) | ✅ Yes |
| Older versions | ⚠️ Best-effort | | Older versions | ⚠️ Best-effort |
| Pre-release or forks | ❌ No | | Pre-release or forks | ❌ No |
+6
View File
@@ -0,0 +1,6 @@
.git
node_modules
dist
.cache
*.log
+18
View File
@@ -0,0 +1,18 @@
# syntax=docker/dockerfile:1.7
FROM node:20-bullseye-slim AS build
WORKDIR /app
ENV npm_config_fund=false \
npm_config_audit=false \
npm_config_progress=false
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --fund=false
COPY . .
RUN --mount=type=cache,target=/root/.npm npm run build
FROM nginx:1.27-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build /app/dist /usr/share/nginx/html
# SPA fallback + asset caching
RUN printf 'server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n location / { try_files $uri $uri/ /index.html; }\n location ~* \\.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { expires 7d; add_header Cache-Control "public, max-age=604800, immutable"; try_files $uri =404; }\n}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx","-g","daemon off;"]
+4 -8
View File
@@ -1,29 +1,25 @@
<!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 - Dev</title> <title>CheckCle</title>
<meta name="description" <meta name="description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
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" <meta property="og:description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
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> </head>
<body> <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>
+119 -191
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -11,7 +11,6 @@
"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",
@@ -46,13 +45,12 @@
"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",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"pocketbase": "^0.19.0", "pocketbase": "^0.26.8",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -1,11 +1,11 @@
[ [
{ {
"id": "pbc_1938176441", "id": "pbc_1938176441",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "alert_configurations", "name": "alert_configurations",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -380,11 +380,11 @@
}, },
{ {
"id": "pbc_2889059272", "id": "pbc_2889059272",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "data_settings", "name": "data_settings",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -478,11 +478,11 @@
}, },
{ {
"id": "pbc_285611395", "id": "pbc_285611395",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "dns_data", "name": "dns_data",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -717,11 +717,11 @@
}, },
{ {
"id": "pbc_3168803865", "id": "pbc_3168803865",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "docker_metrics", "name": "docker_metrics",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -978,11 +978,11 @@
}, },
{ {
"id": "pbc_583547467", "id": "pbc_583547467",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "dockers", "name": "dockers",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -1249,11 +1249,11 @@
}, },
{ {
"id": "pbc_2189087560", "id": "pbc_2189087560",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "incidents", "name": "incidents",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -1511,11 +1511,11 @@
}, },
{ {
"id": "pbc_1738231158", "id": "pbc_1738231158",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "maintenance", "name": "maintenance",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -1741,11 +1741,11 @@
}, },
{ {
"id": "pbc_3662317132", "id": "pbc_3662317132",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "operational_page", "name": "operational_page",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -1932,11 +1932,11 @@
}, },
{ {
"id": "pbc_1168766540", "id": "pbc_1168766540",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "ping_data", "name": "ping_data",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -2199,11 +2199,11 @@
}, },
{ {
"id": "pbc_171698555", "id": "pbc_171698555",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "regional_service", "name": "regional_service",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -2331,11 +2331,11 @@
}, },
{ {
"id": "pbc_1998570700", "id": "pbc_1998570700",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "server_metrics", "name": "server_metrics",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -2648,11 +2648,11 @@
}, },
{ {
"id": "pbc_1965843930", "id": "pbc_1965843930",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "server_notification_templates", "name": "server_notification_templates",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -2948,11 +2948,11 @@
}, },
{ {
"id": "pbc_1464856393", "id": "pbc_1464856393",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "server_threshold_templates", "name": "server_threshold_templates",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -3094,11 +3094,11 @@
}, },
{ {
"id": "pbc_3738798621", "id": "pbc_3738798621",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "servers", "name": "servers",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -3556,11 +3556,11 @@
}, },
{ {
"id": "pbc_763794515", "id": "pbc_763794515",
"listRule": null, "listRule": "@request.auth.id != \"\"",
"viewRule": null, "viewRule": "@request.auth.id != \"\"",
"createRule": null, "createRule": "@request.auth.id != \"\"",
"updateRule": null, "updateRule": "@request.auth.id != \"\"",
"deleteRule": null, "deleteRule": "@request.auth.id != \"\"",
"name": "service_group", "name": "service_group",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -3646,11 +3646,11 @@
}, },
{ {
"id": "pbc_1572233440", "id": "pbc_1572233440",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "service_notification_templates", "name": "service_notification_templates",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -3806,11 +3806,11 @@
}, },
{ {
"id": "pbc_863811952", "id": "pbc_863811952",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "services", "name": "services",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -4162,11 +4162,11 @@
}, },
{ {
"id": "pbc_3575570325", "id": "pbc_3575570325",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "services_metrics", "name": "services_metrics",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -4509,11 +4509,11 @@
}, },
{ {
"id": "pbc_1836745630", "id": "pbc_1836745630",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "ssl_certificates", "name": "ssl_certificates",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -4841,11 +4841,11 @@
}, },
{ {
"id": "pbc_1753185970", "id": "pbc_1753185970",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "ssl_history", "name": "ssl_history",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -5004,11 +5004,11 @@
}, },
{ {
"id": "pbc_351174173", "id": "pbc_351174173",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "ssl_notification_templates", "name": "ssl_notification_templates",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -5122,11 +5122,11 @@
}, },
{ {
"id": "pbc_1636150439", "id": "pbc_1636150439",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "status_page_components", "name": "status_page_components",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -5252,11 +5252,11 @@
}, },
{ {
"id": "pbc_2331916230", "id": "pbc_2331916230",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "tcp_data", "name": "tcp_data",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -5449,11 +5449,11 @@
}, },
{ {
"id": "pbc_67962705", "id": "pbc_67962705",
"listRule": "", "listRule": "@request.auth.id != \"\"",
"viewRule": "", "viewRule": "@request.auth.id != \"\"",
"createRule": "", "createRule": "@request.auth.id != \"\"",
"updateRule": "", "updateRule": "@request.auth.id != \"\"",
"deleteRule": "", "deleteRule": "@request.auth.id != \"\"",
"name": "uptime_data", "name": "uptime_data",
"type": "base", "type": "base",
"fields": [ "fields": [
Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

+41 -135
View File
@@ -1,40 +1,37 @@
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( const [queryClient] = useState(() => new QueryClient({
() =>
new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 60 * 1000, staleTime: 60 * 1000
}, }
}, }
}) }));
);
return ( return (
<BrowserRouter> <BrowserRouter>
@@ -49,110 +46,19 @@ function App() {
{/* Protected routes */} {/* Protected routes */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route <Route path="/" element={<ProtectedRoute><Index /></ProtectedRoute>} />
path="/" <Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
element={ <Route path="/instance-monitoring" element={<ProtectedRoute><InstanceMonitoring /></ProtectedRoute>} />
<ProtectedRoute> <Route path="/server-detail/:serverId" element={<ProtectedRoute><ServerDetail /></ProtectedRoute>} />
<Index /> <Route path="/container-monitoring" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
</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 <Route path="/schedule-incident" element={<ProtectedRoute><ScheduleIncident /></ProtectedRoute>} />
path="/dashboard" <Route path="/operational-page" element={<ProtectedRoute><OperationalPage /></ProtectedRoute>} />
element={ <Route path="/regional-monitoring" element={<ProtectedRoute><RegionalMonitoring /></ProtectedRoute>} />
<ProtectedRoute> <Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Dashboard /> <Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
</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,3 +1,4 @@
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";
@@ -14,24 +15,17 @@ interface DashboardContentProps {
error: Error | null; error: Error | null;
} }
export const DashboardContent = ({ export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
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 = const matchesSearch = service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
service.name.toLowerCase().includes(searchTerm.toLowerCase()) || (service.url && service.url.toLowerCase().includes(searchTerm.toLowerCase()));
(service.url && const matchesFilter = filter === 'all' || service.type.toLowerCase() === filter.toLowerCase();
service.url.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesFilter =
filter === "all" || service.type.toLowerCase() === filter.toLowerCase();
return matchesSearch && matchesFilter; return matchesSearch && matchesFilter;
}); });
@@ -39,23 +33,21 @@ export const DashboardContent = ({
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 dark:bg-black p-2 shadow pb-0 "> <main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
<div className="flex flex-col flex-1 bg-white dark:bg-black p-4 rounded-xl "> <div className="flex flex-col flex-1">
<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"> <h2 className="text-2xl font-bold text-foreground">{t('overview')}</h2>
{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>
+98 -263
View File
@@ -2,349 +2,187 @@ 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 { import {
Moon, Moon, PanelLeft, PanelLeftClose, Sun, Globe, FileText,
PanelLeft, Github, Twitter, MessageSquare, Bell, User, Settings,
PanelLeftClose, LogOut, Menu, X
Sun,
Globe,
FileText,
Github,
Twitter,
MessageSquare,
Bell,
User,
Settings,
LogOut,
CaseUpper,
BookA,
CaseSensitive,
CaseLower,
ALargeSmall,
} from "lucide-react"; } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { import {
DropdownMenu, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuContent, DropdownMenuTrigger, DropdownMenuSeparator
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } 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"; import { useSidebar } from "@/contexts/SidebarContext";
import { cn } from "@/lib/utils";
interface HeaderProps { interface HeaderProps {
currentUser: AuthUser | null; currentUser: AuthUser | null;
onLogout: () => void; onLogout: () => void;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
} }
export const Header = ({ export const Header = ({
currentUser, currentUser,
onLogout, onLogout,
sidebarCollapsed,
toggleSidebar,
}: HeaderProps) => { }: HeaderProps) => {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { language, setLanguage, t } = useLanguage(); const { language, setLanguage, t } = useLanguage();
const { sidebarCollapsed, toggleSidebar, isMobileOpen, toggleMobileMenu } = useSidebar();
const [greeting, setGreeting] = useState<string>(""); const [greeting, setGreeting] = useState<string>("");
const { systemName } = useSystemSettings(); const { systemName } = useSystemSettings();
const navigate = useNavigate(); const navigate = useNavigate();
// Set greeting based on time of day
useEffect(() => { useEffect(() => {
const updateGreeting = () => { const updateGreeting = () => {
const hour = new Date().getHours(); const hour = new Date().getHours();
if (hour >= 5 && hour < 12) { if (hour >= 5 && hour < 12) setGreeting(t("goodMorning"));
setGreeting(t("goodMorning")); else if (hour >= 12 && hour < 18) setGreeting(t("goodAfternoon"));
} else if (hour >= 12 && hour < 18) { else setGreeting(t("goodEvening"));
setGreeting(t("goodAfternoon"));
} else {
setGreeting(t("goodEvening"));
}
}; };
updateGreeting(); updateGreeting();
// Update greeting if language changes
// You could add a timer to update the greeting throughout the day
// but that's typically unnecessary since most sessions aren't active across time periods
}, [language, t]); }, [language, t]);
// Log avatar data for debugging const avatarUrl = currentUser?.avatar || '';
useEffect(() => {
if (currentUser) { const openExternalLink = (url: string) => {
//console.log("Avatar URL in Header:", currentUser.avatar); window.open(url, '_blank');
} };
}, [currentUser]);
// Prepare avatar URL - ensure it displays correctly if it's a local profile image
let avatarUrl = "";
if (currentUser?.avatar) {
// If it's a relative path from the public folder, make sure it's resolved properly
if (currentUser.avatar.startsWith("/upload/profile/")) {
avatarUrl = currentUser.avatar;
} else {
avatarUrl = currentUser.avatar;
}
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-4 lg:px-6 flex justify-between items-center h-16 shrink-0 z-30 overflow-hidden">
{/* Grid Pattern Overlay - Similar to StatusCards */} {/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0 pointer-events-none opacity-20">
<div <div
className="w-full h-full dark:bg-black" className="w-full h-full"
style={{ style={{
backgroundImage: `linear-gradient(${ backgroundImage: `linear-gradient(${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px),
theme === "dark" ? "#ffffff10" : "#00000010" linear-gradient(90deg, ${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px)`,
} 1px, transparent 1px), backgroundSize: '20px 20px'
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>
</div> </div>
</div> </div>
{/* Header Content */} <div className="flex items-center gap-2 lg:gap-4 z-10">
<div className="flex items-center gap-4 z-10"> {/* Mobile Toggle */}
<div <Button variant="ghost" size="icon" onClick={toggleMobileMenu} className="lg:hidden h-9 w-9">
className={` p-1 h-8 w-8 -ml-2 rounded bg-gray-600 ${ {isMobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
!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"> {/* Desktop Toggle */}
<h1 className="font-medium text-xl"> <Button variant="ghost" size="icon" onClick={toggleSidebar} className="hidden lg:flex h-9 w-9">
{greeting},{" "} {sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
{currentUser?.name || currentUser?.email?.split("@")[0] || "User"}{" "} </Button>
👋
<div className="flex items-center">
<h1 className="text-sm lg:text-lg font-medium truncate max-w-[150px] lg:max-w-none">
{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋
</h1> </h1>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4 z-10"> <div className="flex items-center space-x-1 lg:space-x-3 z-10">
{/* External Links - Hidden on small screens */}
<div className="hidden md:flex items-center space-x-1 mr-2">
<Button <Button
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={toggleTheme} onClick={() => openExternalLink('https://docs.checkcle.io')}
title={t("documentation")}
> >
<FileText className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://github.com/operacle/checkcle')}
title="GitHub"
>
<Github className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://x.com/checkcle_oss')}
title="X (Twitter)"
>
<Twitter className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://discord.gg/xs9gbubGwX')}
title="Discord"
>
<MessageSquare className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
title={t("notifications")}
>
<Bell className="w-4 h-4" />
</Button>
</div>
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border hidden sm:flex" onClick={toggleTheme}>
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
{theme === "dark" ? ( {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
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" className="w-40">
<DropdownMenuItem <DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
onClick={() => setLanguage("en")}
className={language === "en" ? "bg-accent" : ""}
>
{t("english")} {t("english")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
onClick={() => setLanguage("km")}
className={language === "km" ? "bg-accent" : ""}
>
{t("khmer")} {t("khmer")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
onClick={() => setLanguage("de")} {t("german")}
className={language === "de" ? "bg-accent" : ""}
>
{t("Deutsch")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => setLanguage("ko")} className={language === "ko" ? "bg-accent" : ""}>
onClick={() => setLanguage("ko")}
className={language === "ko" ? "bg-accent" : ""}
>
{t("korean")} {t("korean")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}>
onClick={() => setLanguage("ja")}
className={language === "ja" ? "bg-accent" : ""}
>
{t("japanese")} {t("japanese")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => setLanguage("zhcn")} className={language === "zhcn" ? "bg-accent" : ""}>
onClick={() => setLanguage("zhcn")}
className={language === "zhcn" ? "bg-accent" : ""}
>
{t("simplifiedChinese")} {t("simplifiedChinese")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Documentation */} <div className="h-8 w-px bg-border mx-1 hidden sm:block" />
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://docs.checkcle.io", "_blank")}
>
<span className="sr-only">{t("documentation")}</span>
<FileText className="w-4 h-4" />
</Button>
{/* GitHub */}
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() =>
window.open("https://github.com/operacle/checkcle", "_blank")
}
>
<span className="sr-only">GitHub</span>
<Github className="w-4 h-4" />
</Button>
{/* X (Twitter) */}
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://x.com/checkcle_oss)", "_blank")}
>
<span className="sr-only">X (Twitter)</span>
<Twitter className="w-4 h-4" />
</Button>
{/* Discord */}
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://discord.gg/xs9gbubGwX", "_blank")}
>
<span className="sr-only">Discord</span>
<MessageSquare className="w-4 h-4" />
</Button>
{/* Notifications */}
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
>
<span className="sr-only">{t("notifications")}</span>
<Bell className="w-4 h-4" />
</Button>
{/* Fond Size */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Avatar className="h-8 w-8 cursor-pointer border hover:ring-2 hover:ring-primary/20 transition-all">
variant="outline" {avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
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 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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">
{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 ? ( {avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
<AvatarImage
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-0.5">
<span className="font-medium"> <span className="text-sm font-medium truncate">{currentUser?.name || 'User'}</span>
{currentUser?.name || "User"} <span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
</span>
<span className="text-xs text-muted-foreground truncate">
{currentUser?.email}
</span>
</div> </div>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -357,10 +195,7 @@ export const Header = ({
<span>{t("settings")}</span> <span>{t("settings")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={onLogout} className="text-red-500 focus:text-red-500">
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,12 +1,7 @@
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 { import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
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";
@@ -23,26 +18,24 @@ 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"> <h3 className="text-xl font-semibold mr-2 text-foreground">{t('currentlyMonitoring')}</h3>
{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 dark:bg-gray-800"> <SelectTrigger className="w-40 bg-card border-border">
<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>
@@ -52,7 +45,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)}
/> />
@@ -3,32 +3,42 @@ import { useTheme } from "@/contexts/ThemeContext";
import { SidebarHeader } from "./sidebar/SidebarHeader"; import { SidebarHeader } from "./sidebar/SidebarHeader";
import { MainNavigation } from "./sidebar/MainNavigation"; import { MainNavigation } from "./sidebar/MainNavigation";
import { SettingsPanel } from "./sidebar/SettingsPanel"; import { SettingsPanel } from "./sidebar/SettingsPanel";
import { useSidebar } from "@/contexts/SidebarContext";
import { cn } from "@/lib/utils";
interface SidebarProps { export const Sidebar = () => {
collapsed: boolean;
}
export const Sidebar = ({ collapsed }: SidebarProps) => {
const { theme } = useTheme(); const { theme } = useTheme();
const { sidebarCollapsed, isMobileOpen, toggleMobileMenu } = useSidebar();
return ( return (
<>
{/* Mobile Overlay */}
{isMobileOpen && (
<div <div
className={` className="fixed inset-0 bg-black/60 z-40 lg:hidden backdrop-blur-sm transition-opacity animate-in fade-in"
${ onClick={toggleMobileMenu}
theme === "dark" />
? "bg-black border-[#1e1e1e]" )}
: "bg-sidebars border-sidebar-border"
} {/* Sidebar Container */}
border-r flex flex-col h-full <aside
${collapsed ? "w-16" : "w-64"} className={cn(
${collapsed ? "hidden" : ""}`} "fixed inset-y-0 left-0 z-50 lg:static lg:block transition-all duration-300 ease-in-out border-r flex flex-col h-full",
theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border',
// Mobile state
isMobileOpen ? "translate-x-0 w-64" : "-translate-x-full lg:translate-x-0",
// Desktop state (collapsed/expanded)
!isMobileOpen && sidebarCollapsed ? "lg:w-16" : "lg:w-64"
)}
> >
<SidebarHeader collapsed={collapsed} /> <SidebarHeader collapsed={!isMobileOpen && sidebarCollapsed} />
<MainNavigation collapsed={collapsed} />
<SettingsPanel collapsed={collapsed} /> <div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar">
<div className="items-center ml-2 p-2 h-30 border-t "> <MainNavigation collapsed={!isMobileOpen && sidebarCollapsed} />
Version: latest
</div>
</div> </div>
<SettingsPanel collapsed={!isMobileOpen && sidebarCollapsed} />
</aside>
</>
); );
}; };
@@ -1,3 +1,4 @@
import React from "react"; import React from "react";
import { MenuItem } from "./MenuItem"; import { MenuItem } from "./MenuItem";
import { mainMenuItems } from "./navigationData"; import { mainMenuItems } from "./navigationData";
@@ -6,15 +7,9 @@ interface MainNavigationProps {
collapsed: boolean; collapsed: boolean;
} }
export const MainNavigation: React.FC<MainNavigationProps> = ({ export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) => {
collapsed,
}) => {
return ( return (
<nav <nav className="my-2 mx-1 py-1 px-1">
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}
@@ -3,6 +3,7 @@ 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 { LucideIcon } from "lucide-react"; import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface MenuItemProps { interface MenuItemProps {
id: string; id: string;
@@ -15,55 +16,51 @@ interface MenuItemProps {
} }
export const MenuItem: React.FC<MenuItemProps> = ({ export const MenuItem: React.FC<MenuItemProps> = ({
id,
path, path,
icon: Icon, icon: Icon,
translationKey, translationKey,
color, color,
hasNavigation, hasNavigation,
collapsed, collapsed
}) => { }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const { t } = useLanguage(); const { t } = useLanguage();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const isActive = path && location.pathname === path;
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if (hasNavigation && path) { if (hasNavigation && path) {
navigate(path, { replace: false }); navigate(path);
} }
}; };
const isActive = path && location.pathname === path;
const mainIconSize = "h-6 w-6";
return ( return (
<div <div
className={` className={cn(
${collapsed ? "p-3" : "p-2 pl-3"} "group relative flex items-center mb-1 rounded-lg cursor-pointer transition-all duration-200",
mb-1 rounded-lg collapsed ? "p-3 justify-center" : "p-2.5 px-4",
${
isActive isActive
? theme === "dark" ? (theme === 'dark' ? 'bg-zinc-800 text-white' : 'bg-primary/10 text-primary')
? "bg-drak" : (theme === 'dark' ? 'text-zinc-400 hover:bg-zinc-800/50 hover:text-white' : 'text-zinc-600 hover:bg-zinc-100')
: "bg-sidebar-accent " )}
: `hover:${theme === "dark" ? "bg-gray-800" : "bg-sidebar-accent "}`
}
flex items-center
${collapsed ? "justify-center" : ""}
cursor-pointer
`}
onClick={handleClick} onClick={handleClick}
title={collapsed ? t(translationKey) : ""}
> >
<Icon className={`${mainIconSize} ${color}`} /> <Icon className={cn("h-5 w-5 shrink-0 transition-colors", color, isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100")} />
{!collapsed && ( {!collapsed && (
<span className="ml-2.5 font-medium text-foreground tracking-wide "> <span className="ml-3 font-medium tracking-wide text-sm truncate">
{t(translationKey)} {t(translationKey)}
</span> </span>
)} )}
{/* Small Indicator for active state */}
{isActive && !collapsed && (
<div className="absolute left-0 w-1 h-6 bg-primary rounded-r-full" />
)}
</div> </div>
); );
}; };
@@ -1,13 +1,10 @@
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 { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
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";
@@ -20,27 +17,19 @@ 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>( const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
"general" const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
);
//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);
} }
} }
localStorage.setItem("settingsPanelOpen", settingsPanelOpen.toString()); }, [location]);
}, [location, settingsPanelOpen]);
const handleSettingsItemClick = (item: string) => { const handleSettingsItemClick = (item: string) => {
setActiveSettingsItem(item); setActiveSettingsItem(item);
@@ -54,27 +43,15 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
}; };
const getMenuItemClasses = (isActive: boolean) => { const getMenuItemClasses = (isActive: boolean) => {
return `p-2 ${ return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
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
className={`border-t ${ onClick={(e) => handleMenuItemClick('/settings', e)}
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`} />
@@ -84,59 +61,33 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
} }
return ( return (
<div <div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
className={`flex-1 flex flex-col border-t ${ <Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
theme === "dark" <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'}`}>
? "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"> <span className="font-medium tracking-wide">{t("settingPanel")}</span>
{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 <ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
className={`h-4 w-4 transition-transform duration-200 ${
settingsPanelOpen ? "rotate-180" : ""
}`}
/>
</div> </div>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent <CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
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 cursor-pointer"> <div className="space-y-2 pr-4">
{settingsMenuItems.map((item) => ( {settingsMenuItems.map((item) => (
<div <div
key={item.id} key={item.id}
className={getMenuItemClasses( className={getMenuItemClasses(activeSettingsItem === item.id)}
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-base">{t(item.translationKey)}</span> <span className="text-sm">{t(item.translationKey)}</span>
</div> </div>
))} ))}
</div> </div>
@@ -1,3 +1,4 @@
import React from "react"; import React from "react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
@@ -9,15 +10,13 @@ export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<div <div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
className={`p-[15.7px] ${ <div className="h-8 w-8 bg-gray-600 rounded flex items-center justify-center mr-2">
theme === "dark" <img
? "border-[#1e1e1e] bg-black" src="/favicon_sidebar.ico"
: "border-sidebar-border bg-white" alt="CheckCle"
} border-b flex items-center ${collapsed ? "justify-center" : ""}`} className="h-6 w-6"
> />
<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>
@@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody } from "@/components/ui/table"; import { Table, TableBody } from "@/components/ui/table";
import { DockerContainer } from "@/types/docker.types"; import { DockerContainer } from "@/types/docker.types";
import { DockerMetricsDialog } from "./DockerMetricsDialog"; import { DockerMetricsDialog } from "./DockerMetricsDialog";
import { useLanguage } from "@/contexts/LanguageContext";
import { import {
DockerTableSearch, DockerTableSearch,
DockerTableHeader, DockerTableHeader,
@@ -18,6 +19,7 @@ interface DockerContainersTableProps {
} }
export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => { export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => {
const { t } = useLanguage();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null); const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null);
const [metricsDialogOpen, setMetricsDialogOpen] = useState(false); const [metricsDialogOpen, setMetricsDialogOpen] = useState(false);
@@ -49,7 +51,7 @@ export const DockerContainersTable = ({ containers, isLoading, onRefresh }: Dock
<CardHeader className="pb-4 px-0"> <CardHeader className="pb-4 px-0">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<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">
<CardTitle className="text-lg sm:text-xl font-semibold">Docker Containers</CardTitle> <CardTitle className="text-lg sm:text-xl font-semibold">{t('dockerContainers', 'docker')}</CardTitle>
<DockerTableSearch <DockerTableSearch
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
@@ -10,6 +10,7 @@ import { DockerContainer, DockerMetrics } from "@/types/docker.types";
import { dockerService } from "@/services/dockerService"; import { dockerService } from "@/services/dockerService";
import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react"; import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerMetricsDialogProps { interface DockerMetricsDialogProps {
container: DockerContainer | null; container: DockerContainer | null;
@@ -20,16 +21,17 @@ interface DockerMetricsDialogProps {
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m'; type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
const timeRangeOptions = [ const timeRangeOptions = [
{ value: '60m' as TimeRange, label: '60 minutes', hours: 1 }, { value: '60m' as TimeRange, label: 'minutes60', hours: 1 },
{ value: '1d' as TimeRange, label: '1 day', hours: 24 }, { value: '1d' as TimeRange, label: 'day1', hours: 24 },
{ value: '7d' as TimeRange, label: '7 days', hours: 24 * 7 }, { value: '7d' as TimeRange, label: 'days7', hours: 24 * 7 },
{ value: '1m' as TimeRange, label: '1 month', hours: 24 * 30 }, { value: '1m' as TimeRange, label: 'month1', hours: 24 * 30 },
{ value: '3m' as TimeRange, label: '3 months', hours: 24 * 90 }, { value: '3m' as TimeRange, label: 'months3', hours: 24 * 90 },
]; ];
export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => { export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => {
const [timeRange, setTimeRange] = useState<TimeRange>("1d"); const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const { theme } = useTheme(); const { theme } = useTheme();
const { t } = useLanguage();
const { const {
data: metrics = [], data: metrics = [],
@@ -236,7 +238,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center"> <div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
<Cpu className="h-4 w-4 text-primary" /> <Cpu className="h-4 w-4 text-primary" />
</div> </div>
Container Metrics: {container.name} {t('containerMetricsTitle', 'docker', { name: container.name })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}> <Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}>
@@ -246,7 +248,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<SelectContent> <SelectContent>
{timeRangeOptions.map((option) => ( {timeRangeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {t(option.label, 'docker')}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -254,22 +256,22 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
</div> </div>
</DialogTitle> </DialogTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Docker ID: {container.docker_id} {container.hostname} {t('dockerId', 'docker')}: {container.docker_id} {container.hostname}
</p> </p>
</DialogHeader> </DialogHeader>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin" /> <Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">Loading metrics...</span> <span className="ml-2">{t('loadingMetrics', 'docker')}</span>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex items-center justify-center h-96 text-muted-foreground"> <div className="flex items-center justify-center h-96 text-muted-foreground">
<p>Error loading metrics: {error.message}</p> <p>{t('errorLoadingMetrics', 'docker')}: {String((error as any)?.message ?? '')}</p>
</div> </div>
) : chartData.length === 0 ? ( ) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-96 text-muted-foreground"> <div className="flex items-center justify-center h-96 text-muted-foreground">
<p>No metrics data available for this container</p> <p>{t('noMetricsAvailable', 'docker')}</p>
</div> </div>
) : ( ) : (
<> <>
@@ -277,7 +279,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
{latestMetric && ( {latestMetric && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard <MetricCard
title="CPU" title={t('cpu', 'docker')}
used={`${latestMetric.cpuUsage}%`} used={`${latestMetric.cpuUsage}%`}
total={`${latestMetric.cpuCores} cores`} total={`${latestMetric.cpuCores} cores`}
free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`} free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`}
@@ -286,7 +288,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
color={chartConfig.cpuUsage.color} color={chartConfig.cpuUsage.color}
/> />
<MetricCard <MetricCard
title="Memory" title={t('memoryTitle', 'docker')}
used={latestMetric.ramUsed} used={latestMetric.ramUsed}
total={latestMetric.ramTotal} total={latestMetric.ramTotal}
free={latestMetric.ramFree} free={latestMetric.ramFree}
@@ -295,7 +297,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
color={chartConfig.ramUsagePercent.color} color={chartConfig.ramUsagePercent.color}
/> />
<MetricCard <MetricCard
title="Disk" title={t('diskTitle', 'docker')}
used={latestMetric.diskUsed} used={latestMetric.diskUsed}
total={latestMetric.diskTotal} total={latestMetric.diskTotal}
free={latestMetric.diskFree} free={latestMetric.diskFree}
@@ -304,10 +306,10 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
color={chartConfig.diskUsagePercent.color} color={chartConfig.diskUsagePercent.color}
/> />
<MetricCard <MetricCard
title="Network" title={t('network', 'docker')}
used={`RX: ${latestMetric.networkRx}`} used={`RX: ${latestMetric.networkRx}`}
total={`TX: ${latestMetric.networkTx}`} total={`TX: ${latestMetric.networkTx}`}
free={`Speed: ${latestMetric.networkRxSpeed} KB/s`} free={`${t('networkSpeedKbs', 'docker').replace('(KB/s)', '')}: ${latestMetric.networkRxSpeed} KB/s`}
percentage={0} percentage={0}
icon={Network} icon={Network}
color={chartConfig.networkRx.color} color={chartConfig.networkRx.color}
@@ -319,19 +321,19 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<TabsList className="grid w-full grid-cols-4 bg-muted"> <TabsList className="grid w-full grid-cols-4 bg-muted">
<TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background"> <TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background">
<Cpu className="h-4 w-4" /> <Cpu className="h-4 w-4" />
CPU {t('cpu', 'docker')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background"> <TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background">
<MemoryStick className="h-4 w-4" /> <MemoryStick className="h-4 w-4" />
Memory {t('memoryTitle', 'docker')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background"> <TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background">
<HardDrive className="h-4 w-4" /> <HardDrive className="h-4 w-4" />
Disk {t('diskTitle', 'docker')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background"> <TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background">
<Network className="h-4 w-4" /> <Network className="h-4 w-4" />
Network {t('network', 'docker')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -339,7 +341,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">CPU Usage (%)</CardTitle> <CardTitle className="text-foreground">{t('cpuUsagePct', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -365,7 +367,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.cpuUsage.color} stroke={chartConfig.cpuUsage.color}
strokeWidth={2} strokeWidth={2}
dot={{ r: 3, fill: chartConfig.cpuUsage.color }} dot={{ r: 3, fill: chartConfig.cpuUsage.color }}
name="CPU Usage (%)" name={t('cpuUsagePct', 'docker')}
/> />
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
@@ -374,7 +376,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">CPU Usage vs Available</CardTitle> <CardTitle className="text-foreground">{t('cpuUsageVsAvailable', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -401,7 +403,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"} stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"} fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3} fillOpacity={0.3}
name="CPU Available (%)" name={t('cpuAvailablePct', 'docker')}
/> />
<Area <Area
type="monotone" type="monotone"
@@ -410,7 +412,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.cpuUsage.color} stroke={chartConfig.cpuUsage.color}
fill={chartConfig.cpuUsage.color} fill={chartConfig.cpuUsage.color}
fillOpacity={0.6} fillOpacity={0.6}
name="CPU Usage (%)" name={t('cpuUsagePct', 'docker')}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
@@ -423,7 +425,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Memory Usage (%)</CardTitle> <CardTitle className="text-foreground">{t('ramUsagePct', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -459,7 +461,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Memory Usage (Bytes)</CardTitle> <CardTitle className="text-foreground">{t('memoryUsageBytes', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -479,8 +481,8 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
content={<ChartTooltipContent className="bg-popover border-border" />} content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }} cursor={{ stroke: getGridColor() }}
formatter={(value, name) => [ formatter={(value, name) => [
name === 'Used Memory' ? formatBytes(Number(value)) : name === t('usedMemory', 'docker') ? formatBytes(Number(value)) :
name === 'Total Memory' ? formatBytes(Number(value)) : value, name === t('totalMemory', 'docker') ? formatBytes(Number(value)) : value,
name name
]} ]}
/> />
@@ -491,7 +493,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"} stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"} fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3} fillOpacity={0.3}
name="Total Memory" name={t('totalMemory', 'docker')}
/> />
<Area <Area
type="monotone" type="monotone"
@@ -500,7 +502,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.ramUsagePercent.color} stroke={chartConfig.ramUsagePercent.color}
fill={chartConfig.ramUsagePercent.color} fill={chartConfig.ramUsagePercent.color}
fillOpacity={0.6} fillOpacity={0.6}
name="Used Memory" name={t('usedMemory', 'docker')}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
@@ -513,7 +515,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Disk Usage (%)</CardTitle> <CardTitle className="text-foreground">{t('diskUsagePct', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -540,7 +542,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.diskUsagePercent.color} stroke={chartConfig.diskUsagePercent.color}
fill={chartConfig.diskUsagePercent.color} fill={chartConfig.diskUsagePercent.color}
fillOpacity={0.6} fillOpacity={0.6}
name="Disk Usage (%)" name={t('diskUsagePct', 'docker')}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
@@ -549,7 +551,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Disk Usage (Bytes)</CardTitle> <CardTitle className="text-foreground">{t('diskUsageBytes', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -569,8 +571,8 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
content={<ChartTooltipContent className="bg-popover border-border" />} content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }} cursor={{ stroke: getGridColor() }}
formatter={(value, name) => [ formatter={(value, name) => [
name === 'Used Disk' ? formatBytes(Number(value)) : name === t('usedDisk', 'docker') ? formatBytes(Number(value)) :
name === 'Total Disk' ? formatBytes(Number(value)) : value, name === t('totalDisk', 'docker') ? formatBytes(Number(value)) : value,
name name
]} ]}
/> />
@@ -581,7 +583,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"} stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"} fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3} fillOpacity={0.3}
name="Total Disk" name={t('totalDisk', 'docker')}
/> />
<Area <Area
type="monotone" type="monotone"
@@ -590,7 +592,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.diskUsagePercent.color} stroke={chartConfig.diskUsagePercent.color}
fill={chartConfig.diskUsagePercent.color} fill={chartConfig.diskUsagePercent.color}
fillOpacity={0.6} fillOpacity={0.6}
name="Used Disk" name={t('usedDisk', 'docker')}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
@@ -603,7 +605,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Network Traffic</CardTitle> <CardTitle className="text-foreground">{t('networkTraffic', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-64"> <ChartContainer config={chartConfig} className="h-64">
@@ -627,7 +629,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
dataKey="networkRxBytes" dataKey="networkRxBytes"
stroke={chartConfig.networkRx.color} stroke={chartConfig.networkRx.color}
strokeWidth={2} strokeWidth={2}
name="RX Bytes" name={t('rxBytes', 'docker')}
dot={{ r: 2 }} dot={{ r: 2 }}
/> />
<Line <Line
@@ -635,7 +637,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
dataKey="networkTxBytes" dataKey="networkTxBytes"
stroke={chartConfig.networkTx.color} stroke={chartConfig.networkTx.color}
strokeWidth={2} strokeWidth={2}
name="TX Bytes" name={t('txBytes', 'docker')}
dot={{ r: 2 }} dot={{ r: 2 }}
/> />
</LineChart> </LineChart>
@@ -645,7 +647,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Network Speed (KB/s)</CardTitle> <CardTitle className="text-foreground">{t('networkSpeedKbs', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-64"> <ChartContainer config={chartConfig} className="h-64">
@@ -669,7 +671,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
dataKey="networkRxSpeed" dataKey="networkRxSpeed"
stroke={chartConfig.networkRx.color} stroke={chartConfig.networkRx.color}
strokeWidth={2} strokeWidth={2}
name="RX Speed (KB/s)" name={t('rxSpeedKbs', 'docker')}
dot={{ r: 2 }} dot={{ r: 2 }}
/> />
<Line <Line
@@ -677,7 +679,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
dataKey="networkTxSpeed" dataKey="networkTxSpeed"
stroke={chartConfig.networkTx.color} stroke={chartConfig.networkTx.color}
strokeWidth={2} strokeWidth={2}
name="TX Speed (KB/s)" name={t('txSpeedKbs', 'docker')}
dot={{ r: 2 }} dot={{ r: 2 }}
/> />
</LineChart> </LineChart>
@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
import { Container, Play, Square, AlertTriangle } from "lucide-react"; import { Container, Play, Square, AlertTriangle } from "lucide-react";
import { DockerStats } from "@/types/docker.types"; import { DockerStats } from "@/types/docker.types";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerStatsCardsProps { interface DockerStatsCardsProps {
stats: DockerStats; stats: DockerStats;
@@ -11,10 +12,11 @@ interface DockerStatsCardsProps {
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => { export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
const { theme } = useTheme(); const { theme } = useTheme();
const { t } = useLanguage();
const cards = [ const cards = [
{ {
title: "Total Containers", title: t('totalContainers', 'docker'),
value: stats.total, value: stats.total,
icon: Container, icon: Container,
color: "text-blue-600", color: "text-blue-600",
@@ -23,7 +25,7 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
}, },
{ {
title: "Running", title: t('running', 'docker'),
value: stats.running, value: stats.running,
icon: Play, icon: Play,
color: "text-green-600", color: "text-green-600",
@@ -32,7 +34,7 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
}, },
{ {
title: "Stopped", title: t('stopped', 'docker'),
value: stats.stopped, value: stats.stopped,
icon: Square, icon: Square,
color: "text-gray-600", color: "text-gray-600",
@@ -41,7 +43,7 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
}, },
{ {
title: "Warning", title: t('warning', 'docker'),
value: stats.warning, value: stats.warning,
icon: AlertTriangle, icon: AlertTriangle,
color: "text-amber-600", color: "text-amber-600",
@@ -90,7 +92,7 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
variant="outline" variant="outline"
className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30" className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30"
> >
Containers {t('containersLabel', 'docker')}
</Badge> </Badge>
</div> </div>
</CardContent> </CardContent>
@@ -1,36 +1,38 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerStatusBadgeProps { interface DockerStatusBadgeProps {
status: 'running' | 'stopped' | 'warning'; status: 'running' | 'stopped' | 'warning';
} }
export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => { export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => {
const { t } = useLanguage();
const getStatusConfig = (status: string) => { const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'running': case 'running':
return { return {
variant: 'default' as const, variant: 'default' as const,
className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200', className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200',
label: 'Running' label: t('running', 'docker')
}; };
case 'stopped': case 'stopped':
return { return {
variant: 'secondary' as const, variant: 'secondary' as const,
className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200', className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
label: 'Stopped' label: t('stopped', 'docker')
}; };
case 'warning': case 'warning':
return { return {
variant: 'destructive' as const, variant: 'destructive' as const,
className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200', className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200',
label: 'Warning' label: t('warning', 'docker')
}; };
default: default:
return { return {
variant: 'outline' as const, variant: 'outline' as const,
className: 'bg-gray-100 text-gray-600 border-gray-200', className: 'bg-gray-100 text-gray-600 border-gray-200',
label: 'Unknown' label: t('unknown', 'docker')
}; };
} }
}; };
@@ -1,20 +1,22 @@
import { TableCell, TableRow } from "@/components/ui/table"; import { TableCell, TableRow } from "@/components/ui/table";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerEmptyStateProps { interface DockerEmptyStateProps {
searchTerm: string; searchTerm: string;
} }
export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => { export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => {
const { t } = useLanguage();
return ( return (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground"> <TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="text-lg font-medium"> <div className="text-lg font-medium">
{searchTerm ? "No containers found" : "No containers running"} {searchTerm ? t('noContainersFound', 'docker') : t('noContainersRunning', 'docker')}
</div> </div>
<div className="text-sm"> <div className="text-sm">
{searchTerm ? "Try adjusting your search terms." : "Start some containers to see them here."} {searchTerm ? t('tryAdjustSearch', 'docker') : t('startSomeContainers', 'docker')}
</div> </div>
</div> </div>
</TableCell> </TableCell>
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react"; import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react";
import { DockerContainer } from "@/types/docker.types"; import { DockerContainer } from "@/types/docker.types";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerRowActionsProps { interface DockerRowActionsProps {
container: DockerContainer; container: DockerContainer;
@@ -12,11 +13,12 @@ interface DockerRowActionsProps {
} }
export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => { export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => {
const { t } = useLanguage();
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted"> <Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted">
<span className="sr-only">Open menu</span> <span className="sr-only">{t('openMenu', 'docker')}</span>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -26,14 +28,14 @@ export const DockerRowActions = ({ container, containerStatus, onContainerAction
className="cursor-pointer hover:bg-muted" className="cursor-pointer hover:bg-muted"
> >
<BarChart3 className="mr-2 h-4 w-4" /> <BarChart3 className="mr-2 h-4 w-4" />
View Metrics {t('viewMetrics', 'docker')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onContainerAction('view-detail', container.id, container.name)} onClick={() => onContainerAction('view-detail', container.id, container.name)}
className="cursor-pointer hover:bg-muted" className="cursor-pointer hover:bg-muted"
> >
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
View Details {t('viewDetails', 'docker')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -1,18 +1,20 @@
import { TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useLanguage } from "@/contexts/LanguageContext";
export const DockerTableHeader = () => { export const DockerTableHeader = () => {
const { t } = useLanguage();
return ( return (
<TableHeader> <TableHeader>
<TableRow className="border-border bg-muted/30"> <TableRow className="border-border bg-muted/30">
<TableHead className="min-w-[200px] font-semibold">Container</TableHead> <TableHead className="min-w-[200px] font-semibold">{t('container', 'docker')}</TableHead>
<TableHead className="min-w-[100px] font-semibold">Status</TableHead> <TableHead className="min-w-[100px] font-semibold">{t('status', 'docker')}</TableHead>
<TableHead className="min-w-[140px] font-semibold">CPU Usage</TableHead> <TableHead className="min-w-[140px] font-semibold">{t('cpuUsage', 'docker')}</TableHead>
<TableHead className="min-w-[160px] font-semibold">Memory</TableHead> <TableHead className="min-w-[160px] font-semibold">{t('memory', 'docker')}</TableHead>
<TableHead className="min-w-[160px] font-semibold">Disk</TableHead> <TableHead className="min-w-[160px] font-semibold">{t('disk', 'docker')}</TableHead>
<TableHead className="min-w-[100px] font-semibold">Uptime</TableHead> <TableHead className="min-w-[100px] font-semibold">{t('uptime', 'docker')}</TableHead>
<TableHead className="min-w-[160px] font-semibold">Last Checked</TableHead> <TableHead className="min-w-[160px] font-semibold">{t('lastChecked', 'docker')}</TableHead>
<TableHead className="min-w-[80px] text-center font-semibold">Actions</TableHead> <TableHead className="min-w-[80px] text-center font-semibold">{t('actions', 'docker')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
); );
@@ -2,6 +2,7 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, RefreshCw } from "lucide-react"; import { Search, RefreshCw } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerTableSearchProps { interface DockerTableSearchProps {
searchTerm: string; searchTerm: string;
@@ -11,12 +12,13 @@ interface DockerTableSearchProps {
} }
export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => { export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => {
const { t } = useLanguage();
return ( return (
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto"> <div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial"> <div className="relative flex-1 sm:flex-initial">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input <Input
placeholder="Search containers..." placeholder={t('searchContainersPlaceholder', 'docker')}
value={searchTerm} value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 sm:w-64 bg-background border-border" className="pl-10 sm:w-64 bg-background border-border"
@@ -30,7 +32,7 @@ export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoa
className="w-full sm:w-auto bg-background border-border hover:bg-muted" className="w-full sm:w-auto bg-background border-border hover:bg-muted"
> >
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
<span className="sm:inline">Refresh</span> <span className="sm:inline">{t('refresh', 'docker')}</span>
</Button> </Button>
</div> </div>
); );
@@ -11,6 +11,7 @@ import { Plus, X, Server } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types'; import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { serviceService } from '@/services/serviceService'; import { serviceService } from '@/services/serviceService';
import { useLanguage } from "@/contexts/LanguageContext";
interface ComponentsSelectorProps { interface ComponentsSelectorProps {
selectedComponents: Partial<StatusPageComponentRecord>[]; selectedComponents: Partial<StatusPageComponentRecord>[];
@@ -19,6 +20,7 @@ interface ComponentsSelectorProps {
} }
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => { export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
const { t } = useLanguage();
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newComponent, setNewComponent] = useState({ const [newComponent, setNewComponent] = useState({
name: '', name: '',
@@ -71,16 +73,16 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" /> <Server className="h-5 w-5" />
Status Page Components {t('statusPageComponents')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Add monitoring components like uptime services, SSL certificates, and incident tracking {t('addMonitoringComponentsDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{selectedComponents.length > 0 && ( {selectedComponents.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Selected Components</Label> <Label>{t('selectedComponents')}</Label>
<div className="space-y-2"> <div className="space-y-2">
{selectedComponents.map((component, index) => ( {selectedComponents.map((component, index) => (
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg"> <div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
@@ -92,12 +94,12 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<div className="flex gap-2 mt-1"> <div className="flex gap-2 mt-1">
{component.service_id && ( {component.service_id && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Service: {services.find(s => s.id === component.service_id)?.name || component.service_id} {t('service')}: {services.find(s => s.id === component.service_id)?.name || component.service_id}
</Badge> </Badge>
)} )}
{component.server_id && ( {component.server_id && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Server: {component.server_id} {t('server')}: {component.server_id}
</Badge> </Badge>
)} )}
</div> </div>
@@ -122,22 +124,22 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
className="w-full" className="w-full"
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Component {t('addComponent')}
</Button> </Button>
) : ( ) : (
<div className="border rounded-lg p-4 space-y-4"> <div className="border rounded-lg p-4 space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="component-name">Component Name</Label> <Label htmlFor="component-name">{t('componentName')}</Label>
<Input <Input
id="component-name" id="component-name"
placeholder="e.g., Main Website" placeholder={t('componentNamePlaceholder')}
value={newComponent.name} value={newComponent.name}
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })} onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
/> />
</div> </div>
<div> <div>
<Label htmlFor="display-order">Display Order</Label> <Label htmlFor="display-order">{t('displayOrder')}</Label>
<Input <Input
id="display-order" id="display-order"
type="number" type="number"
@@ -148,10 +150,10 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
</div> </div>
<div> <div>
<Label htmlFor="component-description">Description (Optional)</Label> <Label htmlFor="component-description">{t('descriptionOptional')}</Label>
<Textarea <Textarea
id="component-description" id="component-description"
placeholder="Brief description of this component" placeholder={t('descriptionPlaceholder')}
value={newComponent.description} value={newComponent.description}
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })} onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
/> />
@@ -159,10 +161,10 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="service-id">Uptime Service (Optional)</Label> <Label htmlFor="service-id">{t('uptimeServiceOptional')}</Label>
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}> <Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select an uptime service" /> <SelectValue placeholder={t('selectUptimeService')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{services.map((service) => ( {services.map((service) => (
@@ -181,10 +183,10 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
</Select> </Select>
</div> </div>
<div> <div>
<Label htmlFor="server-id">Server ID (Optional)</Label> <Label htmlFor="server-id">{t('serverIdOptional')}</Label>
<Input <Input
id="server-id" id="server-id"
placeholder="server_456" placeholder={t('serverIdPlaceholder')}
value={newComponent.server_id} value={newComponent.server_id}
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })} onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
/> />
@@ -193,10 +195,10 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={addComponent} disabled={!newComponent.name.trim()}> <Button onClick={addComponent} disabled={!newComponent.name.trim()}>
Add Component {t('addComponent')}
</Button> </Button>
<Button variant="outline" onClick={() => setShowAddForm(false)}> <Button variant="outline" onClick={() => setShowAddForm(false)}>
Cancel {t('cancel')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -14,6 +14,7 @@ import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
import { ComponentsSelector } from './ComponentsSelector'; import { ComponentsSelector } from './ComponentsSelector';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types'; import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useLanguage } from "@/contexts/LanguageContext";
const formSchema = z.object({ const formSchema = z.object({
title: z.string().min(1, 'Title is required'), title: z.string().min(1, 'Title is required'),
@@ -31,6 +32,7 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
export const CreateOperationalPageDialog = () => { export const CreateOperationalPageDialog = () => {
const { t } = useLanguage();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]); const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
const createMutation = useCreateOperationalPage(); const createMutation = useCreateOperationalPage();
@@ -102,14 +104,14 @@ export const CreateOperationalPageDialog = () => {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2"> <Button className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Page {t('createPage')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Create Operational Page</DialogTitle> <DialogTitle>{t('createOperationalPage')}</DialogTitle>
<DialogDescription> <DialogDescription>
Create a new operational status page to monitor your services and components. {t('createOperationalPageDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -121,9 +123,9 @@ export const CreateOperationalPageDialog = () => {
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Title</FormLabel> <FormLabel>{t('title')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="My Service Status" {...field} /> <Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -135,9 +137,9 @@ export const CreateOperationalPageDialog = () => {
name="slug" name="slug"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Slug</FormLabel> <FormLabel>{t('slug')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-service-status" {...field} /> <Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -150,10 +152,10 @@ export const CreateOperationalPageDialog = () => {
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>{t('description')}</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="A brief description of your operational page" placeholder={t('operationalPageDescriptionPlaceholder')}
className="min-h-[80px]" className="min-h-[80px]"
{...field} {...field}
/> />
@@ -169,17 +171,17 @@ export const CreateOperationalPageDialog = () => {
name="theme" name="theme"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Theme</FormLabel> <FormLabel>{t('theme')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select theme" /> <SelectValue placeholder={t('selectTheme')} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="default">Default</SelectItem> <SelectItem value="default">{t('themeDefault')}</SelectItem>
<SelectItem value="dark">Dark</SelectItem> <SelectItem value="dark">{t('themeDark')}</SelectItem>
<SelectItem value="light">Light</SelectItem> <SelectItem value="light">{t('themeLight')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -192,18 +194,18 @@ export const CreateOperationalPageDialog = () => {
name="status" name="status"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Initial Status</FormLabel> <FormLabel>{t('initialStatus')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select status" /> <SelectValue placeholder={t('selectStatus')} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="operational">Operational</SelectItem> <SelectItem value="operational">{t('statusOperational')}</SelectItem>
<SelectItem value="degraded">Degraded Performance</SelectItem> <SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
<SelectItem value="maintenance">Under Maintenance</SelectItem> <SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
<SelectItem value="major_outage">Major Outage</SelectItem> <SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -218,9 +220,9 @@ export const CreateOperationalPageDialog = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Public Page</FormLabel> <FormLabel>{t('publicPage')}</FormLabel>
<FormDescription> <FormDescription>
Make this page publicly accessible {t('makePagePublic')}
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -238,12 +240,12 @@ export const CreateOperationalPageDialog = () => {
name="custom_domain" name="custom_domain"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Custom Domain (Optional)</FormLabel> <FormLabel>{t('customDomainOptional')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="status.yourdomain.com" {...field} /> <Input placeholder={t('customDomainPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Custom domain for your status page {t('customDomainDescription')}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -261,13 +263,13 @@ export const CreateOperationalPageDialog = () => {
variant="outline" variant="outline"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={createMutation.isPending || createComponentMutation.isPending} disabled={createMutation.isPending || createComponentMutation.isPending}
> >
{createMutation.isPending || createComponentMutation.isPending ? 'Creating...' : 'Create Page'} {createMutation.isPending || createComponentMutation.isPending ? t('creating') : t('createPage')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -14,6 +14,7 @@ import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, u
import { ComponentsSelector } from './ComponentsSelector'; import { ComponentsSelector } from './ComponentsSelector';
import { OperationalPageRecord } from '@/types/operational.types'; import { OperationalPageRecord } from '@/types/operational.types';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types'; import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useLanguage } from "@/contexts/LanguageContext";
const formSchema = z.object({ const formSchema = z.object({
title: z.string().min(1, 'Title is required'), title: z.string().min(1, 'Title is required'),
@@ -37,6 +38,7 @@ interface EditOperationalPageDialogProps {
} }
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => { export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
const { t } = useLanguage();
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]); const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
const [isFormSubmitting, setIsFormSubmitting] = useState(false); const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const [componentsLoaded, setComponentsLoaded] = useState(false); const [componentsLoaded, setComponentsLoaded] = useState(false);
@@ -195,9 +197,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Operational Page</DialogTitle> <DialogTitle>{t('editOperationalPage')}</DialogTitle>
<DialogDescription> <DialogDescription>
Update your operational status page settings and manage components. {t('updateYourOperationalPage')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -209,9 +211,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Title</FormLabel> <FormLabel>{t('title')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="My Service Status" {...field} /> <Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -223,9 +225,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="slug" name="slug"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Slug</FormLabel> <FormLabel>{t('slug')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-service-status" {...field} /> <Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -238,10 +240,10 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>{t('description')}</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="A brief description of your operational page" placeholder={t('operationalPageDescriptionPlaceholder')}
className="min-h-[80px]" className="min-h-[80px]"
{...field} {...field}
/> />
@@ -257,17 +259,17 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="theme" name="theme"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Theme</FormLabel> <FormLabel>{t('theme')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select theme" /> <SelectValue placeholder={t('selectTheme')} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="default">Default</SelectItem> <SelectItem value="default">{t('themeDefault')}</SelectItem>
<SelectItem value="dark">Dark</SelectItem> <SelectItem value="dark">{t('themeDark')}</SelectItem>
<SelectItem value="light">Light</SelectItem> <SelectItem value="light">{t('themeLight')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -280,18 +282,18 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="status" name="status"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Status</FormLabel> <FormLabel>{t('status')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select status" /> <SelectValue placeholder={t('selectStatus')} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="operational">Operational</SelectItem> <SelectItem value="operational">{t('statusOperational')}</SelectItem>
<SelectItem value="degraded">Degraded Performance</SelectItem> <SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
<SelectItem value="maintenance">Under Maintenance</SelectItem> <SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
<SelectItem value="major_outage">Major Outage</SelectItem> <SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -306,9 +308,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Public Page</FormLabel> <FormLabel>{t('publicPage')}</FormLabel>
<FormDescription> <FormDescription>
Make this page publicly accessible {t('makePagePublic')}
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -326,12 +328,12 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="custom_domain" name="custom_domain"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Custom Domain (Optional)</FormLabel> <FormLabel>{t('customDomainOptional')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="status.yourdomain.com" {...field} /> <Input placeholder={t('customDomainPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Custom domain for your status page {t('customDomainDescription')}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -350,13 +352,13 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending} disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
> >
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? 'Updating...' : 'Update Page'} {isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? t('updating') : t('updatePage')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -6,6 +6,7 @@ import { OperationalPageRecord } from '@/types/operational.types';
import { StatusBadge } from './StatusBadge'; import { StatusBadge } from './StatusBadge';
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react'; import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useLanguage } from "@/contexts/LanguageContext";
interface OperationalPageCardProps { interface OperationalPageCardProps {
page: OperationalPageRecord; page: OperationalPageRecord;
@@ -15,6 +16,8 @@ interface OperationalPageCardProps {
} }
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => { export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
const { t } = useLanguage();
return ( return (
<Card className="hover:shadow-lg transition-shadow duration-200"> <Card className="hover:shadow-lg transition-shadow duration-200">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -32,23 +35,23 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="font-medium text-muted-foreground">Slug:</span> <span className="font-medium text-muted-foreground">{t('slug')}:</span>
<p className="mt-1">{page.slug}</p> <p className="mt-1">{page.slug}</p>
</div> </div>
<div> <div>
<span className="font-medium text-muted-foreground">Theme:</span> <span className="font-medium text-muted-foreground">{t('theme')}:</span>
<p className="mt-1 capitalize">{page.theme}</p> <p className="mt-1 capitalize">{page.theme}</p>
</div> </div>
<div> <div>
<span className="font-medium text-muted-foreground">Public:</span> <span className="font-medium text-muted-foreground">{t('public')}:</span>
<div className="mt-1"> <div className="mt-1">
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}> <Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
{page.is_public === 'true' ? 'Yes' : 'No'} {page.is_public === 'true' ? t('yes') : t('no')}
</Badge> </Badge>
</div> </div>
</div> </div>
<div> <div>
<span className="font-medium text-muted-foreground">Updated:</span> <span className="font-medium text-muted-foreground">{t('updated')}:</span>
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p> <p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
</div> </div>
</div> </div>
@@ -70,7 +73,7 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
className="flex-1" className="flex-1"
> >
<Eye className="h-4 w-4 mr-2" /> <Eye className="h-4 w-4 mr-2" />
View {t('view')}
</Button> </Button>
)} )}
{onEdit && ( {onEdit && (
@@ -81,7 +84,7 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
className="flex-1" className="flex-1"
> >
<Settings className="h-4 w-4 mr-2" /> <Settings className="h-4 w-4 mr-2" />
Edit {t('edit')}
</Button> </Button>
)} )}
{onDelete && ( {onDelete && (
@@ -1,17 +1,16 @@
import { useState } from "react"; import { useState } from 'react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { import { useOperationalPages, useDeleteOperationalPage } from '@/hooks/useOperationalPage';
useOperationalPages, import { CreateOperationalPageDialog } from './CreateOperationalPageDialog';
useDeleteOperationalPage, import { EditOperationalPageDialog } from './EditOperationalPageDialog';
} from "@/hooks/useOperationalPage"; import { OperationalPageCard } from './OperationalPageCard';
import { CreateOperationalPageDialog } from "./CreateOperationalPageDialog"; import { Card, CardContent } from '@/components/ui/card';
import { EditOperationalPageDialog } from "./EditOperationalPageDialog"; import { Button } from '@/components/ui/button';
import { OperationalPageCard } from "./OperationalPageCard"; import { OperationalPageRecord } from '@/types/operational.types';
import { Card, CardContent } from "@/components/ui/card"; import { Activity, Plus, RefreshCw } from 'lucide-react';
import { Button } from "@/components/ui/button"; import { Skeleton } from '@/components/ui/skeleton';
import { OperationalPageRecord } from "@/types/operational.types"; import { useLanguage } from "@/contexts/LanguageContext";
import { Activity, Plus, RefreshCw } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -21,26 +20,18 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from '@/components/ui/alert-dialog';
export const OperationalPageContent = () => { export const OperationalPageContent = () => {
const { t } = useLanguage();
const navigate = useNavigate(); const navigate = useNavigate();
const { const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
data: pages,
isLoading,
error,
refetch,
isRefetching,
} = useOperationalPages();
const deleteMutation = useDeleteOperationalPage(); const deleteMutation = useDeleteOperationalPage();
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>( const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(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] = const [pageToDelete, setPageToDelete] = useState<OperationalPageRecord | null>(null);
useState<OperationalPageRecord | null>(null);
const handleEdit = (page: OperationalPageRecord) => { const handleEdit = (page: OperationalPageRecord) => {
setEditingPage(page); setEditingPage(page);
@@ -49,10 +40,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');
} }
}; };
@@ -68,7 +59,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);
} }
} }
}; };
@@ -80,15 +71,13 @@ 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"> <h3 className="text-lg font-semibold mb-2">{t('failedToLoadOperationalPages')}</h3>
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. {t('loadingoperationalPages')}
</p> </p>
<Button onClick={() => refetch()} variant="outline"> <Button onClick={() => refetch()} variant="outline">
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
Try Again {t('tryagain')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -96,15 +85,13 @@ export const OperationalPageContent = () => {
} }
return ( return (
<div className="w-full container mx-auto px-4 py-8 "> <div className="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"> <h1 className="text-3xl font-bold tracking-tight mb-2"> {t('operationalPages')}</h1>
Operational Pages
</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage your public status pages and monitor service health {t('describeOperation')}
</p> </p>
</div> </div>
@@ -115,10 +102,8 @@ export const OperationalPageContent = () => {
onClick={() => refetch()} onClick={() => refetch()}
disabled={isRefetching} disabled={isRefetching}
> >
<RefreshCw <RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
className={`h-4 w-4 mr-2 ${isRefetching ? "animate-spin" : ""}`} {t('refresh')}
/>
Refresh
</Button> </Button>
<CreateOperationalPageDialog /> <CreateOperationalPageDialog />
</div> </div>
@@ -153,12 +138,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"> <h3 className="text-lg font-semibold mb-2">{t('noOperationalPagesFound')}</h3>
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 {t('createYourFirstOperationalPage')}
services and communicate status to your users.
</p> </p>
<CreateOperationalPageDialog /> <CreateOperationalPageDialog />
</CardContent> </CardContent>
@@ -185,15 +167,15 @@ export const OperationalPageContent = () => {
<div className="mt-8 pt-6 border-t"> <div className="mt-8 pt-6 border-t">
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground"> <div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
<div> <div>
<span className="font-medium">Total Pages:</span> {pages.length} <span className="font-medium">{t('totalPages')}:</span> {pages.length}
</div> </div>
<div> <div>
<span className="font-medium">Public Pages:</span>{" "} <span className="font-medium">{t('totalPages')}:</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">{t('operational')}:</span>{' '}
{pages.filter((p) => p.status === "operational").length} {pages.filter(p => p.status === 'operational').length}
</div> </div>
</div> </div>
</div> </div>
@@ -210,21 +192,19 @@ export const OperationalPageContent = () => {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle> <AlertDialogTitle>{t('deleteOperationalPage')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete "{pageToDelete?.title}"? This {t('deleteOperationalPageConfirm').replace('{title}', pageToDelete?.title ?? '')}
action cannot be undone and will permanently remove the
operational page and all its components.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDelete} onClick={confirmDelete}
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 ? t('deleting') : t('delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -5,6 +5,7 @@ import { format } from 'date-fns';
import { OperationalPageRecord } from '@/types/operational.types'; import { OperationalPageRecord } from '@/types/operational.types';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types'; import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { Service } from '@/types/service.types'; import { Service } from '@/types/service.types';
import { useLanguage } from '@/contexts/LanguageContext';
interface CurrentStatusSectionProps { interface CurrentStatusSectionProps {
page: OperationalPageRecord; page: OperationalPageRecord;
@@ -45,18 +46,18 @@ const getActualStatus = (components: StatusPageComponentRecord[], services: Serv
return 'operational'; return 'operational';
}; };
const getStatusMessage = (status: OperationalPageRecord['status']) => { const getStatusMessage = (status: OperationalPageRecord['status'], t: (k: string, m?: string) => string) => {
switch (status) { switch (status) {
case 'operational': case 'operational':
return 'All systems are operational'; return t('allOperational', 'public');
case 'degraded': case 'degraded':
return 'Some systems are experiencing degraded performance'; return t('degradedPerformance', 'public');
case 'maintenance': case 'maintenance':
return 'Systems are currently under maintenance'; return t('underMaintenance', 'public');
case 'major_outage': case 'major_outage':
return 'We are experiencing a major service outage'; return t('majorOutage', 'public');
default: default:
return 'Status unknown'; return t('statusUnknown', 'public');
} }
}; };
@@ -106,6 +107,7 @@ const getStatusBackground = (status: OperationalPageRecord['status']) => {
}; };
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => { export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
const { t } = useLanguage();
const actualStatus = getActualStatus(components, services); const actualStatus = getActualStatus(components, services);
const displayStatus = actualStatus; // Use actual status for real-time accuracy const displayStatus = actualStatus; // Use actual status for real-time accuracy
@@ -114,7 +116,7 @@ export const CurrentStatusSection = ({ page, components, services }: CurrentStat
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl"> <CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
<Shield className="h-6 w-6" /> <Shield className="h-6 w-6" />
System Status {t('systemStatus', 'public')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@@ -123,10 +125,10 @@ export const CurrentStatusSection = ({ page, components, services }: CurrentStat
{getStatusIcon(displayStatus)} {getStatusIcon(displayStatus)}
<div> <div>
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}> <h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
{getStatusMessage(displayStatus)} {getStatusMessage(displayStatus, t)}
</h3> </h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Status automatically updated based on component health {t('autoUpdatedByHealth', 'public')}
</p> </p>
</div> </div>
</div> </div>
@@ -136,20 +138,20 @@ export const CurrentStatusSection = ({ page, components, services }: CurrentStat
displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}> }`}>
{displayStatus === 'operational' ? 'All Systems Operational' : {displayStatus === 'operational' ? t('allOperational', 'public') :
displayStatus === 'degraded' ? 'Degraded Performance' : displayStatus === 'degraded' ? t('degradedPerformance', 'public') :
displayStatus === 'maintenance' ? 'Under Maintenance' : 'Major Outage'} displayStatus === 'maintenance' ? t('underMaintenance', 'public') : t('majorOutage', 'public')}
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4"> <div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm')} UTC</span> <span>{t('lastUpdatedAt', 'public', { time: format(new Date(), 'MMM dd, yyyy HH:mm') })}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div> <div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Live status monitoring</span> <span>{t('liveStatusMonitoring', 'public')}</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -9,8 +9,10 @@ import { CurrentStatusSection } from './CurrentStatusSection';
import { ComponentsStatusSection } from './ComponentsStatusSection'; import { ComponentsStatusSection } from './ComponentsStatusSection';
import { OverallUptimeSection } from './OverallUptimeSection'; import { OverallUptimeSection } from './OverallUptimeSection';
import { PublicStatusPageFooter } from './PublicStatusPageFooter'; import { PublicStatusPageFooter } from './PublicStatusPageFooter';
import { useLanguage } from '@/contexts/LanguageContext';
export const PublicStatusPage = () => { export const PublicStatusPage = () => {
const { t } = useLanguage();
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
// console.log('PublicStatusPage - slug from params:', slug); // console.log('PublicStatusPage - slug from params:', slug);
@@ -59,9 +61,9 @@ export const PublicStatusPage = () => {
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-lg font-medium text-foreground">Loading Status Page</p> <p className="text-lg font-medium text-foreground">{t('loadingStatusPage', 'public')}</p>
<p className="text-sm text-muted-foreground">Fetching real-time system status...</p> <p className="text-sm text-muted-foreground">{t('fetchingRealtimeStatus', 'public')}</p>
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p> <p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -76,19 +78,19 @@ export const PublicStatusPage = () => {
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" /> <AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Status Page Not Found</h1> <h1 className="text-2xl font-bold text-foreground">{t('statusPageNotFound', 'public')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{error || 'The requested status page could not be found or is not publicly accessible.'} {error || t('notFoundDescription', 'public')}
</p> </p>
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p> <p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
</div> </div>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button onClick={() => window.history.back()} variant="outline"> <Button onClick={() => window.history.back()} variant="outline">
Go Back {t('goBack', 'public')}
</Button> </Button>
<Button onClick={() => window.location.reload()} className="gap-2"> <Button onClick={() => window.location.reload()} className="gap-2">
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
Retry {t('retry', 'public')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,15 +1,12 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Copy, Download, Terminal, CheckCircle, Zap, Play } from "lucide-react";
import { regionalService } from "@/services/regionalService";
import { InstallCommand } from "@/types/regional.types";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
import { getCurrentEndpoint } from "@/lib/pocketbase";
import { RegionalAgentConfigForm } from "./RegionalAgentConfigForm";
import { RegionalOneClickTab } from "./RegionalOneClickTab";
import { RegionalManualTab } from "./RegionalManualTab";
interface AddRegionalAgentDialogProps { interface AddRegionalAgentDialogProps {
open: boolean; open: boolean;
@@ -22,30 +19,54 @@ export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
onOpenChange, onOpenChange,
onAgentAdded onAgentAdded
}) => { }) => {
const [step, setStep] = useState(1); const { t } = useLanguage();
const [regionName, setRegionName] = useState("");
const [agentIp, setAgentIp] = useState("");
const [installCommand, setInstallCommand] = useState<InstallCommand | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const [activeTab, setActiveTab] = useState("configure");
const [formData, setFormData] = useState({
regionName: "",
agentIp: "",
});
const [agentToken, setAgentToken] = useState("");
const [agentId, setAgentId] = useState("");
const [currentPocketBaseUrl, setCurrentPocketBaseUrl] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleCreateAgent = async (e: React.FormEvent) => { // Generate new credentials when dialog opens or after successful creation
const generateNewCredentials = () => {
const newToken = `rgn_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
const newAgentId = `regional_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
setAgentToken(newToken);
setAgentId(newAgentId);
};
useEffect(() => {
if (open) {
const endpoint = getCurrentEndpoint();
setCurrentPocketBaseUrl(endpoint);
generateNewCredentials();
}
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!regionName.trim() || !agentIp.trim()) return; if (!formData.regionName.trim() || !formData.agentIp.trim()) return;
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const result = await regionalService.createRegionalService({ await new Promise(resolve => setTimeout(resolve, 1000));
region_name: regionName,
agent_ip_address: agentIp,
});
setInstallCommand(result.installCommand); toast({
setStep(2); title: t('success'),
description: t('agentCreatedSuccessfully'),
});
setActiveTab("one-click");
generateNewCredentials();
onAgentAdded();
} catch (error) { } catch (error) {
toast({ toast({
title: "Error", title: t('error'),
description: "Failed to create regional agent configuration.", description: t('failedToCreateAgent'),
variant: "destructive", variant: "destructive",
}); });
} finally { } finally {
@@ -53,407 +74,65 @@ export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
} }
}; };
const copyToClipboard = async (text: string, description: string = "Content") => { const handleDialogClose = () => {
try { setFormData({
// Try the modern clipboard API first regionName: "",
if (navigator.clipboard && window.isSecureContext) { agentIp: "",
await navigator.clipboard.writeText(text);
toast({
title: "Copied!",
description: `${description} copied to clipboard.`,
}); });
return; setActiveTab("configure");
} generateNewCredentials();
// Fallback for older browsers or non-secure contexts (like localhost)
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
toast({
title: "Copied!",
description: `${description} copied to clipboard.`,
});
} else {
throw new Error('execCommand failed');
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Show the text in a modal or alert as final fallback
const userAgent = navigator.userAgent.toLowerCase();
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let errorMessage = "Failed to copy to clipboard.";
if (isLocalhost) {
errorMessage += " This is common in local development. Please manually copy the text.";
} else if (userAgent.includes('chrome')) {
errorMessage += " Try using HTTPS or enable clipboard permissions.";
}
toast({
title: "Copy failed",
description: errorMessage,
variant: "destructive",
});
// As a last resort, select the text for manual copying
try {
const textarea = document.querySelector('textarea[readonly]') as HTMLTextAreaElement;
if (textarea) {
textarea.select();
textarea.setSelectionRange(0, 99999); // For mobile devices
}
} catch (selectError) {
// console.error('Failed to select text:', selectError);
}
}
};
const downloadScript = () => {
if (!installCommand) return;
const blob = new Blob([installCommand.bash_script], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `install-regional-agent-${installCommand.agent_id}.sh`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "Downloaded!",
description: "Installation script downloaded successfully.",
});
};
const handleComplete = () => {
onAgentAdded();
setStep(1);
setRegionName("");
setAgentIp("");
setInstallCommand(null);
onOpenChange(false); onOpenChange(false);
}; };
const resetDialog = () => {
setStep(1);
setRegionName("");
setAgentIp("");
setInstallCommand(null);
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto"> <DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Regional Monitoring Agent</DialogTitle> <DialogTitle>{t('addRegionalMonitoringAgent')}</DialogTitle>
<DialogDescription> <DialogDescription>
Deploy a regional monitoring agent with automatic one-click installation. {t('deployRegionalMonitoringAgent')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{step === 1 && ( <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<form onSubmit={handleCreateAgent} className="space-y-6">
<div className="space-y-4">
<div>
<Label htmlFor="regionName">Region Name</Label>
<Input
id="regionName"
placeholder="e.g., us-east-1, europe-west-1, asia-pacific-1"
value={regionName}
onChange={(e) => setRegionName(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="agentIp">Agent Server IP Address</Label>
<Input
id="agentIp"
placeholder="e.g., 192.168.1.100 or your-server.example.com"
value={agentIp}
onChange={(e) => setAgentIp(e.target.value)}
required
/>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Generating..." : "Generate Installation"}
</Button>
</div>
</form>
)}
{step === 2 && installCommand && (
<div className="space-y-6">
<div className="text-center space-y-2">
<CheckCircle className="h-12 w-12 text-green-600 mx-auto" />
<h3 className="text-lg font-semibold">Agent Configuration Ready!</h3>
<p className="text-muted-foreground">
One-click installation script generated with automatic configuration.
</p>
</div>
<Tabs defaultValue="oneclicK" className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="oneclicK">One-Click Install</TabsTrigger> <TabsTrigger value="configure">{t('configureAgent')}</TabsTrigger>
<TabsTrigger value="details">Agent Details</TabsTrigger> <TabsTrigger value="one-click">{t('oneClickInstallTab')}</TabsTrigger>
<TabsTrigger value="manual">Manual Install</TabsTrigger> <TabsTrigger value="manual">{t('manualInstallTab')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="oneclicK" className="space-y-4"> <TabsContent value="configure" className="space-y-6">
<Card> <RegionalAgentConfigForm
<CardHeader> formData={formData}
<CardTitle className="flex items-center gap-2"> setFormData={setFormData}
<Zap className="h-5 w-5 text-yellow-500" /> agentId={agentId}
One-Click Automatic Installation agentToken={agentToken}
</CardTitle> currentPocketBaseUrl={currentPocketBaseUrl}
<CardDescription> isSubmitting={isSubmitting}
Complete installation, configuration, and service startup in one command onSubmit={handleSubmit}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-green-800 font-medium mb-2">
<Play className="h-4 w-4" />
What this script does automatically:
</div>
<ul className="text-sm text-green-700 space-y-1 ml-6">
<li> Downloads the latest .deb package</li>
<li> Installs the regional monitoring agent</li>
<li> Creates configuration file with your settings</li>
<li> Starts and enables the service</li>
<li> Runs health checks</li>
</ul>
</div>
<div className="space-y-2">
<Label>Run this command on your target server:</Label>
<div className="relative">
<Textarea
readOnly
value={`# One-click installation command:
curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- \\
--region-name="${regionName}" \\
--agent-id="${installCommand.agent_id}" \\
--agent-ip="${agentIp}" \\
--token="${installCommand.token}" \\
--pocketbase-url="${installCommand.api_endpoint}"`}
className="font-mono text-sm min-h-[120px] pr-12"
/> />
<Button
size="sm"
variant="outline"
className="absolute top-2 right-2"
onClick={() => copyToClipboard(`curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- --region-name="${regionName}" --agent-id="${installCommand.agent_id}" --agent-ip="${agentIp}" --token="${installCommand.token}" --pocketbase-url="${installCommand.api_endpoint}"`, "Installation command")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex gap-2">
<Button onClick={downloadScript} variant="outline" className="flex-1">
<Download className="mr-2 h-4 w-4" />
Download Complete Script
</Button>
<Button
onClick={() => copyToClipboard(installCommand.bash_script, "Installation script")}
variant="outline"
className="flex-1"
>
<Copy className="mr-2 h-4 w-4" />
Copy Full Script
</Button>
</div>
{/* Local development notice */}
{(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
<strong>Local Development Notice:</strong> If clipboard copying fails, this is normal in local development.
You can use the "Download Complete Script" button instead or manually copy the text.
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="details" className="space-y-4"> <TabsContent value="one-click" className="space-y-6">
<Card> <RegionalOneClickTab
<CardHeader> agentToken={agentToken}
<CardTitle>Generated Agent Configuration</CardTitle> currentPocketBaseUrl={currentPocketBaseUrl}
<CardDescription> formData={formData}
These values will be automatically configured during installation agentId={agentId}
</CardDescription> onDialogClose={handleDialogClose}
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium text-foreground">Agent ID</Label>
<div className="relative">
<Input
value={installCommand.agent_id}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
/> />
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(installCommand.agent_id, "Agent ID")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div>
<Label className="text-sm font-medium text-foreground">Region Name</Label>
<Input
value={regionName}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
/>
</div>
<div>
<Label className="text-sm font-medium text-foreground">Server IP</Label>
<Input
value={agentIp}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
/>
</div>
<div>
<Label className="text-sm font-medium text-foreground">API Endpoint</Label>
<Input
value={installCommand.api_endpoint}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
/>
</div>
</div>
<div>
<Label className="text-sm font-medium text-foreground">Authentication Token</Label>
<div className="relative">
<Input
value={installCommand.token}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
/>
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(installCommand.token, "Authentication token")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Configuration file location:</strong> /etc/regional-check-agent/regional-check-agent.env
</p>
</div>
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="manual" className="space-y-4"> <TabsContent value="manual" className="space-y-6">
<Card> <RegionalManualTab
<CardHeader> agentToken={agentToken}
<CardTitle className="flex items-center gap-2"> currentPocketBaseUrl={currentPocketBaseUrl}
<Terminal className="h-5 w-5" /> formData={formData}
Manual Installation Steps agentId={agentId}
</CardTitle> onDialogClose={handleDialogClose}
<CardDescription> />
Step-by-step manual installation process
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm space-y-4">
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 1: Download Package</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
<p className="text-xs text-muted-foreground mt-1"> If youre using the amd64 architecture, please download this package. </p> <br/>
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_amd64.deb
</code>
<code className="text-xs bg-muted p-2 rounded block mt-1">
<p className="text-xs text-muted-foreground mt-1"> If youre using the arm64 architecture, please download this package. </p> <br/>
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_arm64.deb
</code>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 2: Install Package</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo dpkg -i distributed-regional-check-agent_1.0.0_amd64.deb<br/>
sudo apt-get install -f
</code>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 3: Configure Agent</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo nano /etc/regional-check-agent/regional-check-agent.env
</code>
<code className="text-xs bg-muted p-2 rounded block mt-1">
Copy sample configuration from https://github.com/operacle/Distributed-Regional-Monitoring/blob/main/packaging/regional-check-agent.conf
</code>
<p className="text-xs text-muted-foreground mt-1">Add the configuration values shown in the Agent Details tab</p>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 4: Start Service</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo systemctl enable regional-check-agent<br/>
sudo systemctl start regional-check-agent
</code>
</div>
<div className="border-l-4 border-green-500 pl-4">
<p className="font-medium">Step 5: Verify Installation</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo systemctl status regional-check-agent<br/>
curl http://localhost:8091/health
</code>
</div>
</div>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={resetDialog}>
Add Another Agent
</Button>
<Button onClick={handleComplete}>
Complete Setup
</Button>
</div>
</div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -8,6 +8,7 @@ import { MapPin, Wifi, WifiOff, MoreVertical, Trash2, Terminal, Copy } from "luc
import { RegionalService } from "@/types/regional.types"; import { RegionalService } from "@/types/regional.types";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
interface RegionalAgentCardProps { interface RegionalAgentCardProps {
agent: RegionalService; agent: RegionalService;
@@ -16,6 +17,7 @@ interface RegionalAgentCardProps {
export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => { export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useLanguage();
// Check if this is the default agent that cannot be removed // Check if this is the default agent that cannot be removed
const isDefaultAgent = agent.agent_id === "1" || agent.region_name === "Default"; const isDefaultAgent = agent.agent_id === "1" || agent.region_name === "Default";
@@ -24,13 +26,13 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
try { try {
await navigator.clipboard.writeText(agent.agent_id); await navigator.clipboard.writeText(agent.agent_id);
toast({ toast({
title: "Copied!", title: t('copied'),
description: "Agent ID copied to clipboard.", description: t('copiedDescription'),
}); });
} catch (error) { } catch (error) {
toast({ toast({
title: "Copy failed", title: t('copyFailed'),
description: "Failed to copy agent ID.", description: t('copyFailedDescription'),
variant: "destructive", variant: "destructive",
}); });
} }
@@ -40,14 +42,14 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
if (agent.connection === 'online') { if (agent.connection === 'online') {
return { return {
icon: <Wifi className="h-4 w-4" />, icon: <Wifi className="h-4 w-4" />,
label: 'Online', label: t('online'),
variant: 'default' as const, variant: 'default' as const,
className: 'bg-green-100 text-green-800 hover:bg-green-100' className: 'bg-green-100 text-green-800 hover:bg-green-100'
}; };
} else { } else {
return { return {
icon: <WifiOff className="h-4 w-4" />, icon: <WifiOff className="h-4 w-4" />,
label: 'Offline', label: t('offline'),
variant: 'secondary' as const, variant: 'secondary' as const,
className: 'bg-red-100 text-red-800 hover:bg-red-100' className: 'bg-red-100 text-red-800 hover:bg-red-100'
}; };
@@ -67,7 +69,7 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
{agent.region_name} {agent.region_name}
{isDefaultAgent && ( {isDefaultAgent && (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200"> <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
Default {t('defaultBadge')}
</Badge> </Badge>
)} )}
</CardTitle> </CardTitle>
@@ -86,12 +88,12 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyAgentId}> <DropdownMenuItem onClick={copyAgentId}>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
Copy Agent ID {t('copyAgentId')}
</DropdownMenuItem> </DropdownMenuItem>
{!isDefaultAgent && ( {!isDefaultAgent && (
<DropdownMenuItem onClick={onDelete} className="text-red-600"> <DropdownMenuItem onClick={onDelete} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Remove Agent {t('removeAgent')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@@ -116,12 +118,12 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Agent ID:</span> <span className="text-muted-foreground">{t('agentId')}</span>
<span className="font-mono text-xs">{agent.agent_id.substring(0, 12)}...</span> <span className="font-mono text-xs">{agent.agent_id.substring(0, 12)}...</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Last Updated:</span> <span className="text-muted-foreground">{t('lastUpdated')}</span>
<span className="text-xs"> <span className="text-xs">
{formatDistanceToNow(new Date(agent.updated), { addSuffix: true })} {formatDistanceToNow(new Date(agent.updated), { addSuffix: true })}
</span> </span>
@@ -132,7 +134,7 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="flex items-center text-xs text-green-600"> <div className="flex items-center text-xs text-green-600">
<div className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></div> <div className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></div>
Active monitoring {t('activeMonitoring')}
</div> </div>
</div> </div>
)} )}
@@ -141,7 +143,7 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="flex items-center text-xs text-red-600"> <div className="flex items-center text-xs text-red-600">
<div className="w-2 h-2 bg-red-600 rounded-full mr-2"></div> <div className="w-2 h-2 bg-red-600 rounded-full mr-2"></div>
Connection lost {t('connectionLost')}
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,128 @@
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Copy } from "lucide-react";
import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
interface RegionalAgentConfigFormProps {
formData: {
regionName: string;
agentIp: string;
};
setFormData: (data: { regionName: string; agentIp: string }) => void;
agentId: string;
agentToken: string;
currentPocketBaseUrl: string;
isSubmitting: boolean;
onSubmit: (e: React.FormEvent) => void;
}
export const RegionalAgentConfigForm: React.FC<RegionalAgentConfigFormProps> = ({
formData,
setFormData,
agentId,
agentToken,
currentPocketBaseUrl,
isSubmitting,
onSubmit,
}) => {
const { t } = useLanguage();
return (
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-4">
<div>
<Label htmlFor="regionName">{t('regionName')}</Label>
<Input
id="regionName"
placeholder={t('regionNamePlaceholder')}
value={formData.regionName}
onChange={(e) => setFormData({ ...formData, regionName: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="agentIp">{t('agentServerIpAddress')}</Label>
<Input
id="agentIp"
placeholder={t('agentIpPlaceholder')}
value={formData.agentIp}
onChange={(e) => setFormData({ ...formData, agentIp: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium">{t('agentId')}</Label>
<div className="relative">
<Input
value={agentId}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(agentId)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div>
<Label className="text-sm font-medium">{t('apiEndpoint')}</Label>
<div className="relative">
<Input
value={currentPocketBaseUrl}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(currentPocketBaseUrl)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
<div>
<Label className="text-sm font-medium">{t('authenticationToken')}</Label>
<div className="relative">
<Input
value={agentToken}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(agentToken)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t('creating') : t('createAgent')}
</Button>
</div>
</form>
);
};
@@ -0,0 +1,159 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Copy, Terminal } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
interface RegionalManualTabProps {
agentToken: string;
currentPocketBaseUrl: string;
formData: {
regionName: string;
agentIp: string;
};
agentId: string;
onDialogClose: () => void;
}
export const RegionalManualTab: React.FC<RegionalManualTabProps> = ({
agentToken,
currentPocketBaseUrl,
formData,
agentId,
onDialogClose,
}) => {
const { t } = useLanguage();
const getManualInstallSteps = () => {
return [
{
title: t('step1DownloadPackage'),
commands: [
{
label: t('downloadAmd64Notice'),
command: 'wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_amd64.deb'
},
{
label: t('downloadArm64Notice'),
command: 'wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_arm64.deb'
}
]
},
{
title: t('step2InstallPackage'),
commands: [
{
label: '',
command: 'sudo dpkg -i distributed-regional-check-agent_1.0.0_amd64.deb\nsudo apt-get install -f'
}
]
},
{
title: t('step3ConfigureAgent'),
commands: [
{
label: '',
command: 'sudo nano /etc/regional-check-agent/regional-check-agent.env'
},
{
label: t('addConfigurationValues'),
command: `POCKETBASE_URL=${currentPocketBaseUrl}
AGENT_TOKEN=${agentToken}
AGENT_ID=${agentId}
REGION_NAME=${formData.regionName}
AGENT_IP=${formData.agentIp}`
}
]
},
{
title: t('step4StartService'),
commands: [
{
label: '',
command: 'sudo systemctl enable regional-check-agent\nsudo systemctl start regional-check-agent'
}
]
},
{
title: t('step5VerifyInstallation'),
commands: [
{
label: '',
command: 'sudo systemctl status regional-check-agent\ncurl http://localhost:8091/health'
}
]
}
];
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
{t('manualInstallationSteps')}
</CardTitle>
<CardDescription>
{t('stepByStepManualInstallation')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
{getManualInstallSteps().map((step, stepIndex) => (
<div key={stepIndex} className="border-l-4 border-blue-500 pl-4 space-y-2">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm font-medium">
{stepIndex + 1}
</span>
<p className="font-medium">{step.title}</p>
</div>
{step.commands.map((cmd, cmdIndex) => (
<div key={cmdIndex} className="space-y-1">
{cmd.label && (
<p className="text-xs text-muted-foreground ml-8">{cmd.label}</p>
)}
<div className="ml-8 relative">
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all">
<code>{cmd.command}</code>
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="absolute top-2 right-2"
onClick={() => copyToClipboard(cmd.command)}
>
<Copy className="h-4 w-4 mr-1" />
Copy
</Button>
</div>
</div>
))}
</div>
))}
</div>
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">Prerequisites:</h4>
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1 list-disc list-inside mb-3">
<li>Ensure you have root/sudo access on the target server</li>
<li>Make sure wget or curl is installed for downloading files</li>
<li>Internet connection required for downloading packages</li>
</ul>
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">After Installation:</h4>
<p className="text-sm text-blue-800 dark:text-blue-200">
The agent will start automatically and appear in your dashboard within a few minutes.
</p>
</div>
<div className="flex justify-end pt-4">
<Button onClick={onDialogClose}>
{t('done')}
</Button>
</div>
</CardContent>
</Card>
);
};
@@ -1,80 +1,68 @@
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 { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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";
import { AddRegionalAgentDialog } from "./AddRegionalAgentDialog"; import { AddRegionalAgentDialog } from "./AddRegionalAgentDialog";
import { RegionalAgentCard } from "./RegionalAgentCard"; import { RegionalAgentCard } from "./RegionalAgentCard";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
export const RegionalMonitoringContent = () => { export const RegionalMonitoringContent = () => {
const { t } = useLanguage();
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const { data: regionalServices = [], isLoading, error } = useQuery({
data: regionalServices = [], queryKey: ['regional-services'],
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: t('regionalAgentAdded'),
description: description: t('regionalAgentAddedDesc'),
"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: t('agentRemoved'),
description: "The regional monitoring agent has been removed.", description: t('agentRemovedDesc'),
}); });
} catch (error) { } catch (error) {
toast({ toast({
title: "Error", title: t('error'),
description: "Failed to remove the regional monitoring agent.", description: t('failedToRemoveAgent'),
variant: "destructive", variant: "destructive",
}); });
} }
}; };
const onlineAgents = regionalServices.filter( const onlineAgents = regionalServices.filter(agent => agent.connection === 'online').length;
(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"> <h1 className="text-3xl font-bold tracking-tight">{t('regionalmonitoring')}</h1>
Regional Monitoring
</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage distributed monitoring agents across different regions {t('descriptRegionPage')}
</p> </p>
</div> </div>
<Button onClick={() => setAddDialogOpen(true)}> <Button onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Regional Agent {t('addRegionalAgent')}
</Button> </Button>
</div> </div>
@@ -82,49 +70,47 @@ export const RegionalMonitoringContent = () => {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<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">Total Agents</CardTitle> <CardTitle className="text-sm font-medium">{t('totalAgents')}</CardTitle>
<MapPin className="h-4 w-4 text-muted-foreground" /> <MapPin className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{totalAgents}</div> <div className="text-2xl font-bold">{totalAgents}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Regional monitoring agents {t('regionalMonitoringAgents')}
</p> </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">Online Agents</CardTitle> <CardTitle className="text-sm font-medium">{t('onlineAgents')}</CardTitle>
<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"> <div className="text-2xl font-bold text-green-600">{onlineAgents}</div>
{onlineAgents} <p className="text-xs text-muted-foreground">
</div> {t('currentlyConnected')}
<p className="text-xs text-muted-foreground">Currently connected</p> </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"> <CardTitle className="text-sm font-medium">{t('offlineAgents')}</CardTitle>
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"> <div className="text-2xl font-bold text-red-600">{totalAgents - onlineAgents}</div>
{totalAgents - onlineAgents} <p className="text-xs text-muted-foreground">
</div> {t('disconnectedAgents')}
<p className="text-xs text-muted-foreground">Disconnected agents</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Agents List */} {/* Agents List */}
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold">Regional Agents</h2> <h2 className="text-xl font-semibold">{t('regionalAgents')}</h2>
{isLoading ? ( {isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -147,14 +133,13 @@ export const RegionalMonitoringContent = () => {
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<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">{t('noRegionalAgents')}</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 {t('getStartedAddAgent')}
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" />
Add First Agent {t('addFirstAgent')}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -0,0 +1,126 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Copy, Download, Zap, Play } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
import { useToast } from "@/hooks/use-toast";
interface RegionalOneClickTabProps {
agentToken: string;
currentPocketBaseUrl: string;
formData: {
regionName: string;
agentIp: string;
};
agentId: string;
onDialogClose: () => void;
}
export const RegionalOneClickTab: React.FC<RegionalOneClickTabProps> = ({
agentToken,
currentPocketBaseUrl,
formData,
agentId,
onDialogClose,
}) => {
const { t } = useLanguage();
const { toast } = useToast();
const getOneClickCommand = () => {
return `curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- \\
--region-name="${formData.regionName}" \\
--agent-id="${agentId}" \\
--agent-ip="${formData.agentIp}" \\
--token="${agentToken}" \\
--pocketbase-url="${currentPocketBaseUrl}"`;
};
const downloadScript = () => {
const scriptContent = getOneClickCommand();
const blob = new Blob([scriptContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `install-regional-agent-${agentId}.sh`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: t('downloaded'),
description: t('downloadedDescription'),
});
};
return (
<Card className="border-green-500/20 bg-green-50/50 dark:bg-green-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400">
<Zap className="h-5 w-5" />
{t('oneClickAutomaticInstallation')}
</CardTitle>
<CardDescription className="text-green-600 dark:text-green-300">
{t('completeInstallationDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4 dark:bg-green-950/50 dark:border-green-800">
<div className="flex items-center gap-2 text-green-800 dark:text-green-200 font-medium mb-2">
<Play className="h-4 w-4" />
{t('whatThisScriptDoes')}
</div>
<ul className="text-sm text-green-700 dark:text-green-300 space-y-1 ml-6">
<li> {t('scriptActionDownload')}</li>
<li> {t('scriptActionInstall')}</li>
<li> {t('scriptActionConfigure')}</li>
<li> {t('scriptActionStart')}</li>
<li> {t('scriptActionHealthChecks')}</li>
</ul>
</div>
<div className="space-y-2">
<Label className="text-green-700 dark:text-green-400">{t('runCommandOnServer')}</Label>
<div className="relative">
<Textarea
readOnly
value={getOneClickCommand()}
className="font-mono text-sm min-h-[120px] pr-12 bg-muted/50 border-green-200 dark:border-green-800"
/>
<Button
type="button"
size="sm"
variant="outline"
className="absolute top-2 right-2 bg-green-50 dark:bg-green-950/50 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900"
onClick={() => copyToClipboard(getOneClickCommand())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex gap-2">
<Button onClick={downloadScript} variant="outline" className="flex-1">
<Download className="mr-2 h-4 w-4" />
{t('downloadCompleteScript')}
</Button>
</div>
{(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 dark:bg-yellow-950/20 dark:border-yellow-800">
<p className="text-sm text-yellow-800 dark:text-yellow-200" dangerouslySetInnerHTML={{ __html: t('localDevelopmentNotice') }} />
</div>
)}
<div className="flex justify-end pt-4">
<Button onClick={onDialogClose}>
{t('done')}
</Button>
</div>
</CardContent>
</Card>
);
};
@@ -1,26 +1,22 @@
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 { import { initMaintenanceNotifications, stopMaintenanceNotifications } from '@/services/maintenance/maintenanceNotificationService';
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] = const [createMaintenanceDialogOpen, setCreateMaintenanceDialogOpen] = useState(false);
useState(false); const [createIncidentDialogOpen, setCreateIncidentDialogOpen] = 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);
@@ -52,8 +48,8 @@ export const ScheduleIncidentContent = () => {
// Show success toast // Show success toast
toast({ toast({
title: t("success"), title: t('success'),
description: t("maintenanceCreatedSuccess"), description: t('maintenanceCreatedSuccess'),
}); });
}; };
@@ -65,26 +61,24 @@ 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 dark:bg-black p-2 "> <main className="flex-1 flex flex-col overflow-auto bg-background p-6">
<div className="flex flex-col gap-6 bg-white dark:bg-black p-3 h-full rounded-xl"> <div className="flex flex-col gap-6">
<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" {activeTab === "maintenance" ? t('createMaintenanceWindow') : t('createIncident')}
? t("createMaintenanceWindow")
: t("createIncident")}
</Button> </Button>
</div> </div>
@@ -96,11 +90,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>
@@ -16,6 +16,7 @@ import { ServerAgentConfigForm } from "./ServerAgentConfigForm";
import { OneClickInstallTab } from "./OneClickInstallTab"; import { OneClickInstallTab } from "./OneClickInstallTab";
import { DockerOneClickTab } from "./DockerOneClickTab"; import { DockerOneClickTab } from "./DockerOneClickTab";
import { ManualInstallTab } from "./ManualInstallTab"; import { ManualInstallTab } from "./ManualInstallTab";
import { useLanguage } from "@/contexts/LanguageContext";
interface AddServerAgentDialogProps { interface AddServerAgentDialogProps {
open: boolean; open: boolean;
@@ -28,15 +29,13 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
onOpenChange, onOpenChange,
onAgentAdded, onAgentAdded,
}) => { }) => {
const { t } = useLanguage();
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState("configure"); const [activeTab, setActiveTab] = useState("configure");
// Get current PocketBase URL
const currentPocketBaseUrl = getCurrentEndpoint(); const currentPocketBaseUrl = getCurrentEndpoint();
// Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
serverName: "", serverName: "",
description: "", description: "",
@@ -47,56 +46,25 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
notificationEnabled: true, notificationEnabled: true,
}); });
// Generated server token and agent ID const generateNewCredentials = () => ({
const [serverToken] = useState(() => serverToken: `srv_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`,
serverId: `agent_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
});
const [serverToken, setServerToken] = useState(() =>
`srv_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` `srv_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
); );
const [serverId] = useState(() => const [serverId, setServerId] = useState(() =>
`agent_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` `agent_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
); );
const handleSubmit = async (e: React.FormEvent) => { // Refresh credentials and reset form whenever the dialog opens
e.preventDefault(); React.useEffect(() => {
if (isSubmitting) return; if (open) {
const newCredentials = generateNewCredentials();
if (!formData.serverName || !formData.osType) { setServerToken(newCredentials.serverToken);
toast({ setServerId(newCredentials.serverId);
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
// Here you would typically create the server monitoring configuration
// For now, we'll simulate the process
await new Promise(resolve => setTimeout(resolve, 1500));
toast({
title: "Server Agent Created",
description: `${formData.serverName} monitoring agent has been configured successfully.`,
});
// Switch to one-click install tab after successful creation
setActiveTab("one-click");
onAgentAdded();
} catch (error) {
toast({
title: "Error",
description: "Failed to create server monitoring agent.",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
const handleDialogClose = () => {
// Reset form and tab when dialog closes
setActiveTab("configure"); setActiveTab("configure");
setFormData({ setFormData({
serverName: "", serverName: "",
@@ -107,6 +75,45 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
dockerEnabled: false, dockerEnabled: false,
notificationEnabled: true, notificationEnabled: true,
}); });
}
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
if (!formData.serverName || !formData.osType) {
toast({
title: t('validationError'),
description: t('fillRequiredFields'),
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
await new Promise(resolve => setTimeout(resolve, 1500));
toast({
title: t('serverAgentCreated'),
description: t('serverAgentCreatedDesc').replace('{name}', formData.serverName),
});
setActiveTab("one-click");
onAgentAdded();
} catch (error) {
toast({
title: t('error'),
description: t('failedToCreateAgent'),
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
const handleDialogClose = () => {
onOpenChange(false); onOpenChange(false);
}; };
@@ -116,19 +123,19 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Server className="h-5 w-5" /> <Server className="h-5 w-5" />
Add Server Monitoring Agent {t('addServerMonitoringAgent')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Configure a new server monitoring agent to track system metrics and performance. {t('configureAgentDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4"> <TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="configure">Configure Agent</TabsTrigger> <TabsTrigger value="configure">{t('configureAgent')}</TabsTrigger>
<TabsTrigger value="one-click">One-Click Install</TabsTrigger> <TabsTrigger value="one-click">{t('oneClickInstall')}</TabsTrigger>
<TabsTrigger value="docker-one-click">Docker One-Click</TabsTrigger> <TabsTrigger value="docker-one-click">{t('dockerOneClick')}</TabsTrigger>
<TabsTrigger value="manual">Manual Installation</TabsTrigger> <TabsTrigger value="manual">{t('manualInstallation')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="configure" className="space-y-6"> <TabsContent value="configure" className="space-y-6">
@@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
import { Copy, Download, Container } from "lucide-react"; import { Copy, Download, Container } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils"; import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerOneClickTabProps { interface DockerOneClickTabProps {
serverToken: string; serverToken: string;
@@ -26,6 +27,7 @@ export const DockerOneClickTab: React.FC<DockerOneClickTabProps> = ({
serverId, serverId,
onDialogClose, onDialogClose,
}) => { }) => {
const { t } = useLanguage();
const getDockerOneClickCommand = () => { const getDockerOneClickCommand = () => {
const scriptUrl = "https://cdn.checkcle.io/scripts/server-docker-agent.sh"; const scriptUrl = "https://cdn.checkcle.io/scripts/server-docker-agent.sh";
@@ -86,15 +88,15 @@ sudo -E bash ./server-docker-agent.sh`;
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-700 dark:text-blue-400"> <CardTitle className="flex items-center gap-2 text-blue-700 dark:text-blue-400">
<Container className="h-5 w-5" /> <Container className="h-5 w-5" />
Docker One-Click Install {t('dockerOneClickTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-blue-600 dark:text-blue-300"> <CardDescription className="text-blue-600 dark:text-blue-300">
Automated Docker container installation with system monitoring capabilities {t('dockerOneClickDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-blue-700 dark:text-blue-400">Docker One-Click Command</Label> <Label className="text-blue-700 dark:text-blue-400">{t('dockerOneClickCommand')}</Label>
<div className="relative"> <div className="relative">
<pre className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-blue-800 dark:text-blue-200"> <pre className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-blue-800 dark:text-blue-200">
<code>{getDockerOneClickCommand()}</code> <code>{getDockerOneClickCommand()}</code>
@@ -107,18 +109,18 @@ sudo -E bash ./server-docker-agent.sh`;
onClick={handleCopyOneClickCommand} onClick={handleCopyOneClickCommand}
> >
<Copy className="h-4 w-4 mr-1" /> <Copy className="h-4 w-4 mr-1" />
Copy {t('copy')}
</Button> </Button>
</div> </div>
</div> </div>
<div className="text-sm text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 p-3 rounded-md"> <div className="text-sm text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 p-3 rounded-md">
<p className="font-medium mb-1">This script will automatically:</p> <p className="font-medium mb-1">{t('dockerScriptWill')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs"> <ol className="list-decimal list-inside space-y-1 text-xs">
<li>Download and setup the Docker monitoring agent</li> <li>{t('dockerScriptStep1')}</li>
<li>Configure all required environment variables</li> <li>{t('dockerScriptStep2')}</li>
<li>Start the container with proper system access</li> <li>{t('dockerScriptStep3')}</li>
<li>Setup monitoring data persistence</li> <li>{t('dockerScriptStep4')}</li>
</ol> </ol>
</div> </div>
</CardContent> </CardContent>
@@ -129,15 +131,15 @@ sudo -E bash ./server-docker-agent.sh`;
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Container className="h-5 w-5" /> <Container className="h-5 w-5" />
Direct Docker Run Command {t('directDockerTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
If you prefer to run the Docker container directly without the script {t('directDockerDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Docker Run Command</Label> <Label>{t('dockerRunCommand')}</Label>
<div className="relative"> <div className="relative">
<pre className="bg-muted border p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all"> <pre className="bg-muted border p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all">
<code>{getDirectDockerCommand()}</code> <code>{getDirectDockerCommand()}</code>
@@ -150,17 +152,17 @@ sudo -E bash ./server-docker-agent.sh`;
onClick={handleCopyDockerCommand} onClick={handleCopyDockerCommand}
> >
<Copy className="h-4 w-4 mr-1" /> <Copy className="h-4 w-4 mr-1" />
Copy {t('copy')}
</Button> </Button>
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground bg-muted/50 border p-3 rounded-md"> <div className="text-sm text-muted-foreground bg-muted/50 border p-3 rounded-md">
<p className="font-medium mb-1">Prerequisites for direct Docker run:</p> <p className="font-medium mb-1">{t('dockerPrerequisites')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs"> <ol className="list-decimal list-inside space-y-1 text-xs">
<li>Docker must be installed and running</li> <li>{t('dockerPrereqStep1')}</li>
<li>The operacle/checkcle-server-agent image must be available</li> <li>{t('dockerPrereqStep2')}</li>
<li>Run as root or with sudo privileges</li> <li>{t('dockerPrereqStep3')}</li>
</ol> </ol>
</div> </div>
</CardContent> </CardContent>
@@ -168,7 +170,7 @@ sudo -E bash ./server-docker-agent.sh`;
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button onClick={onDialogClose}> <Button onClick={onDialogClose}>
Done {t('done')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -14,6 +14,7 @@ import { RefreshCw, X } from "lucide-react";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService"; import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { templateService, ServerNotificationTemplate } from "@/services/templateService"; import { templateService, ServerNotificationTemplate } from "@/services/templateService";
import { serverThresholdService, ServerThreshold } from "@/services/serverThresholdService"; import { serverThresholdService, ServerThreshold } from "@/services/serverThresholdService";
import { useLanguage } from "@/contexts/LanguageContext";
interface EditServerDialogProps { interface EditServerDialogProps {
server: Server | null; server: Server | null;
@@ -46,6 +47,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
onOpenChange, onOpenChange,
onServerUpdated, onServerUpdated,
}) => { }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<ServerFormData>({ const [formData, setFormData] = useState<ServerFormData>({
name: "", name: "",
check_interval: 60, check_interval: 60,
@@ -114,7 +116,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
// Handle the API response format with proper field names and type conversion // Handle the API response format with proper field names and type conversion
setThresholdFormData({ setThresholdFormData({
cpu_threshold: parseInt(String(existingThreshold.cpu_threshold)) || 80, cpu_threshold: parseInt(String(existingThreshold.cpu_threshold)) || 80,
ram_threshold: parseInt(String((existingThreshold as any).ram_threshold_message || existingThreshold.ram_threshold)) || 80, ram_threshold: parseInt(String((existingThreshold.ram_threshold_message ?? existingThreshold.ram_threshold))) || 80,
disk_threshold: parseInt(String(existingThreshold.disk_threshold)) || 80, disk_threshold: parseInt(String(existingThreshold.disk_threshold)) || 80,
network_threshold: parseInt(String(existingThreshold.network_threshold)) || 80, network_threshold: parseInt(String(existingThreshold.network_threshold)) || 80,
}); });
@@ -141,7 +143,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
// Handle the API response format with proper field names and type conversion // Handle the API response format with proper field names and type conversion
setThresholdFormData({ setThresholdFormData({
cpu_threshold: parseInt(String(threshold.cpu_threshold)) || 80, cpu_threshold: parseInt(String(threshold.cpu_threshold)) || 80,
ram_threshold: parseInt(String((threshold as any).ram_threshold_message || threshold.ram_threshold)) || 80, ram_threshold: parseInt(String((threshold.ram_threshold_message ?? threshold.ram_threshold))) || 80,
disk_threshold: parseInt(String(threshold.disk_threshold)) || 80, disk_threshold: parseInt(String(threshold.disk_threshold)) || 80,
network_threshold: parseInt(String(threshold.network_threshold)) || 80, network_threshold: parseInt(String(threshold.network_threshold)) || 80,
}); });
@@ -165,8 +167,8 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: t('error', 'instance'),
description: "Failed to load notification channels", description: t('failedToLoadChannels', 'instance'),
}); });
} finally { } finally {
setLoadingAlertConfigs(false); setLoadingAlertConfigs(false);
@@ -182,8 +184,8 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: t('error', 'instance'),
description: "Failed to load templates", description: t('failedToLoadTemplates', 'instance'),
}); });
} finally { } finally {
setLoadingTemplates(false); setLoadingTemplates(false);
@@ -198,8 +200,8 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: t('error', 'instance'),
description: "Failed to load server thresholds", description: t('failedToLoadThresholds', 'instance'),
}); });
} finally { } finally {
setLoadingThresholds(false); setLoadingThresholds(false);
@@ -274,15 +276,15 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Warning", title: t('warning', 'instance'),
description: "Server updated but failed to update threshold values.", description: t('serverUpdatedButThresholdFailed', 'instance'),
}); });
} }
} }
toast({ toast({
title: "Server updated", title: t('serverUpdated', 'instance'),
description: `${formData.name} has been updated successfully.`, description: t('serverUpdatedDesc', 'instance', { name: formData.name }),
}); });
onServerUpdated(); onServerUpdated();
@@ -291,8 +293,8 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: t('error', 'instance'),
description: "Failed to update server. Please try again.", description: t('failedToUpdateServer', 'instance'),
}); });
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -323,62 +325,62 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto"> <DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Server Configuration</DialogTitle> <DialogTitle>{t('editServerConfiguration', 'instance')}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="serverName">Server Name *</Label> <Label htmlFor="serverName">{t('serverNameLabel', 'instance')}</Label>
<Input <Input
id="serverName" id="serverName"
value={formData.name} value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Enter server name" placeholder={t('serverNamePlaceholder', 'instance')}
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="checkInterval">Check Interval</Label> <Label htmlFor="checkInterval">{t('checkIntervalLabel', 'instance')}</Label>
<Select <Select
value={formData.check_interval.toString()} value={formData.check_interval.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, check_interval: parseInt(value) }))} onValueChange={(value) => setFormData(prev => ({ ...prev, check_interval: parseInt(value) }))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select interval" /> <SelectValue placeholder={t('selectInterval', 'instance')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="30">30 seconds</SelectItem> <SelectItem value="30">{t('interval30s', 'instance')}</SelectItem>
<SelectItem value="60">1 minute</SelectItem> <SelectItem value="60">{t('interval1m', 'instance')}</SelectItem>
<SelectItem value="120">2 minutes</SelectItem> <SelectItem value="120">{t('interval2m', 'instance')}</SelectItem>
<SelectItem value="300">5 minutes</SelectItem> <SelectItem value="300">{t('interval5m', 'instance')}</SelectItem>
<SelectItem value="600">10 minutes</SelectItem> <SelectItem value="600">{t('interval10m', 'instance')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="maxRetries">Max Retries</Label> <Label htmlFor="maxRetries">{t('maxRetriesLabel', 'instance')}</Label>
<Select <Select
value={formData.max_retries.toString()} value={formData.max_retries.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, max_retries: parseInt(value) }))} onValueChange={(value) => setFormData(prev => ({ ...prev, max_retries: parseInt(value) }))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select max retries" /> <SelectValue placeholder={t('selectMaxRetries', 'instance')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">1 retry</SelectItem> <SelectItem value="1">{t('retry1', 'instance')}</SelectItem>
<SelectItem value="2">2 retries</SelectItem> <SelectItem value="2">{t('retry2', 'instance')}</SelectItem>
<SelectItem value="3">3 retries</SelectItem> <SelectItem value="3">{t('retry3', 'instance')}</SelectItem>
<SelectItem value="5">5 retries</SelectItem> <SelectItem value="5">{t('retry5', 'instance')}</SelectItem>
<SelectItem value="10">10 retries</SelectItem> <SelectItem value="10">{t('retry10', 'instance')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="dockerMonitoring">Docker Monitoring</Label> <Label htmlFor="dockerMonitoring">{t('dockerMonitoring', 'instance')}</Label>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
id="dockerMonitoring" id="dockerMonitoring"
@@ -389,7 +391,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
}))} }))}
/> />
<Label htmlFor="dockerMonitoring" className="text-sm text-muted-foreground"> <Label htmlFor="dockerMonitoring" className="text-sm text-muted-foreground">
{formData.docker_monitoring ? "Enabled" : "Disabled"} {formData.docker_monitoring ? t('enabled', 'instance') : t('disabled', 'instance')}
</Label> </Label>
</div> </div>
</div> </div>
@@ -407,22 +409,22 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
// Remove the automatic clearing of notification_channels, threshold_id, and template_id // Remove the automatic clearing of notification_channels, threshold_id, and template_id
}))} }))}
/> />
<Label htmlFor="notificationEnabled">Enable Notifications</Label> <Label htmlFor="notificationEnabled">{t('enableNotifications', 'instance')}</Label>
</div> </div>
{/* Expanded Notification Settings */} {/* Expanded Notification Settings */}
{formData.notification_enabled && ( {formData.notification_enabled && (
<Card className="border-l-4 border-l-blue-500"> <Card className="border-l-4 border-l-blue-500">
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Notification Settings</CardTitle> <CardTitle className="text-lg">{t('notificationSettings', 'instance')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Multiple Notification Channels Selection */} {/* Multiple Notification Channels Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Notification Channels</Label> <Label>{t('notificationChannels', 'instance')}</Label>
<div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3"> <div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3">
{loadingAlertConfigs ? ( {loadingAlertConfigs ? (
<div className="text-sm text-muted-foreground">Loading channels...</div> <div className="text-sm text-muted-foreground">{t('loadingChannels', 'instance')}</div>
) : alertConfigs.length > 0 ? ( ) : alertConfigs.length > 0 ? (
alertConfigs.map((config) => ( alertConfigs.map((config) => (
<div key={config.id} className="flex items-center space-x-2"> <div key={config.id} className="flex items-center space-x-2">
@@ -442,14 +444,14 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
</div> </div>
)) ))
) : ( ) : (
<div className="text-sm text-muted-foreground">No notification channels available</div> <div className="text-sm text-muted-foreground">{t('noChannelsAvailable', 'instance')}</div>
)} )}
</div> </div>
{/* Selected Channels Display */} {/* Selected Channels Display */}
{formData.notification_channels.length > 0 && ( {formData.notification_channels.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Selected Channels:</Label> <Label className="text-sm font-medium">{t('selectedChannels', 'instance')}</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{formData.notification_channels.map((channelId) => { {formData.notification_channels.map((channelId) => {
const channel = alertConfigs.find(c => c.id === channelId); const channel = alertConfigs.find(c => c.id === channelId);
@@ -478,17 +480,17 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
{/* Server Set Threshold Selection */} {/* Server Set Threshold Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="thresholdId">Server Set Threshold</Label> <Label htmlFor="thresholdId">{t('serverSetThreshold', 'instance')}</Label>
<Select <Select
value={formData.threshold_id} value={formData.threshold_id}
onValueChange={(value) => setFormData(prev => ({ ...prev, threshold_id: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, threshold_id: value }))}
disabled={loadingThresholds} disabled={loadingThresholds}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={loadingThresholds ? "Loading thresholds..." : "Select server threshold"} /> <SelectValue placeholder={loadingThresholds ? t('loadingThresholds', 'instance') : t('selectServerThreshold', 'instance')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No threshold (use default)</SelectItem> <SelectItem value="none">{t('noThreshold', 'instance')}</SelectItem>
{thresholds.map((threshold) => ( {thresholds.map((threshold) => (
<SelectItem key={threshold.id} value={threshold.id}> <SelectItem key={threshold.id} value={threshold.id}>
{threshold.name} {threshold.name}
@@ -502,12 +504,12 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
{selectedThreshold && ( {selectedThreshold && (
<Card className="bg-muted/50"> <Card className="bg-muted/50">
<CardHeader> <CardHeader>
<CardTitle className="text-base">Threshold Details: {selectedThreshold.name}</CardTitle> <CardTitle className="text-base">{t('thresholdDetails', 'instance')}: {selectedThreshold.name}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">CPU Threshold (%)</Label> <Label className="text-xs font-medium text-muted-foreground">{t('cpuThresholdPct', 'instance')}</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
@@ -521,7 +523,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
/> />
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">RAM Threshold (%)</Label> <Label className="text-xs font-medium text-muted-foreground">{t('ramThresholdPct', 'instance')}</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
@@ -535,7 +537,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
/> />
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Disk Threshold (%)</Label> <Label className="text-xs font-medium text-muted-foreground">{t('diskThresholdPct', 'instance')}</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
@@ -549,7 +551,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
/> />
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Network Threshold (%)</Label> <Label className="text-xs font-medium text-muted-foreground">{t('networkThresholdPct', 'instance')}</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
@@ -569,17 +571,17 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
{/* Server Template Selection */} {/* Server Template Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="templateId">Server Template</Label> <Label htmlFor="templateId">{t('serverTemplate', 'instance')}</Label>
<Select <Select
value={formData.template_id} value={formData.template_id}
onValueChange={(value) => setFormData(prev => ({ ...prev, template_id: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, template_id: value }))}
disabled={loadingTemplates} disabled={loadingTemplates}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={loadingTemplates ? "Loading templates..." : "Select server template"} /> <SelectValue placeholder={loadingTemplates ? t('loadingTemplates', 'instance') : t('selectServerTemplate', 'instance')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No template (use default)</SelectItem> <SelectItem value="none">{t('noTemplate', 'instance')}</SelectItem>
{templates.map((template) => ( {templates.map((template) => (
<SelectItem key={template.id} value={template.id}> <SelectItem key={template.id} value={template.id}>
{template.name} {template.name}
@@ -593,44 +595,44 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
{selectedTemplate && ( {selectedTemplate && (
<Card className="bg-muted/50"> <Card className="bg-muted/50">
<CardHeader> <CardHeader>
<CardTitle className="text-base">Template Details: {selectedTemplate.name}</CardTitle> <CardTitle className="text-base">{t('templateDetails', 'instance')}: {selectedTemplate.name}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 text-sm"> <div className="grid grid-cols-1 gap-3 text-sm">
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">RAM Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('ramMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.ram_message || "No RAM message defined"} {selectedTemplate.ram_message || t('noMessageDefined', 'instance', { name: 'RAM' })}
</p> </p>
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">CPU Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('cpuMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.cpu_message || "No CPU message defined"} {selectedTemplate.cpu_message || t('noMessageDefined', 'instance', { name: 'CPU' })}
</p> </p>
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Disk Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('diskMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.disk_message || "No disk message defined"} {selectedTemplate.disk_message || t('noMessageDefined', 'instance', { name: 'disk' })}
</p> </p>
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Network Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('networkMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.network_message || "No network message defined"} {selectedTemplate.network_message || t('noMessageDefined', 'instance', { name: 'network' })}
</p> </p>
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Up Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('upMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.up_message || "No up message defined"} {selectedTemplate.up_message || t('noMessageDefined', 'instance', { name: 'up' })}
</p> </p>
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Down Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('downMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.down_message || "No down message defined"} {selectedTemplate.down_message || t('noMessageDefined', 'instance', { name: 'down' })}
</p> </p>
</div> </div>
</div> </div>
@@ -649,16 +651,16 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
onClick={handleCancel} onClick={handleCancel}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('cancel', 'instance')}
</Button> </Button>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting ? ( {isSubmitting ? (
<> <>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" /> <RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Updating... {t('updating', 'instance')}
</> </>
) : ( ) : (
"Update Server" t('updateServer', 'instance')
)} )}
</Button> </Button>
</div> </div>
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { Copy, Terminal } from "lucide-react"; import { Copy, Terminal } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils"; import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
interface ManualInstallTabProps { interface ManualInstallTabProps {
serverToken: string; serverToken: string;
@@ -24,20 +25,22 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
serverId, serverId,
onDialogClose, onDialogClose,
}) => { }) => {
const { t } = useLanguage();
const getManualInstallSteps = () => { const getManualInstallSteps = () => {
const scriptUrl = "https://cdn.checkcle.io/scripts/server-agent.sh"; const scriptUrl = "https://cdn.checkcle.io/scripts/server-agent.sh";
return [ return [
{ {
title: "Download the installation script", title: t('downloadScript'),
command: `curl -L -o server-agent.sh "${scriptUrl}"` command: `curl -L -o server-agent.sh "${scriptUrl}"`
}, },
{ {
title: "Make the script executable", title: t('makeExecutable'),
command: `chmod +x server-agent.sh` command: `chmod +x server-agent.sh`
}, },
{ {
title: "Run the installation with your configuration", title: t('runInstall'),
command: `SERVER_TOKEN="${serverToken}" POCKETBASE_URL="${currentPocketBaseUrl}" SERVER_NAME="${formData.serverName}" AGENT_ID="${serverId}" sudo bash server-agent.sh` command: `SERVER_TOKEN="${serverToken}" POCKETBASE_URL="${currentPocketBaseUrl}" SERVER_NAME="${formData.serverName}" AGENT_ID="${serverId}" sudo bash server-agent.sh`
} }
]; ];
@@ -48,26 +51,26 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" /> <Terminal className="h-5 w-5" />
Manual Installation Steps {t('manualInstallTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Step-by-step installation process {t('manualInstallDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="font-medium">Server Name:</span> {formData.serverName} <span className="font-medium">{t('serverName')}:</span> {formData.serverName}
</div> </div>
<div> <div>
<span className="font-medium">Agent ID:</span> {serverId} <span className="font-medium">{t('agentId')}:</span> {serverId}
</div> </div>
<div> <div>
<span className="font-medium">OS Type:</span> {formData.osType} <span className="font-medium">{t('osType')}:</span> {formData.osType}
</div> </div>
<div> <div>
<span className="font-medium">Check Interval:</span> {formData.checkInterval}s <span className="font-medium">{t('checkInterval')}:</span> {formData.checkInterval}s
</div> </div>
</div> </div>
</div> </div>
@@ -93,7 +96,7 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
onClick={() => copyToClipboard(step.command)} onClick={() => copyToClipboard(step.command)}
> >
<Copy className="h-4 w-4 mr-1" /> <Copy className="h-4 w-4 mr-1" />
Copy {t('copy')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -101,22 +104,22 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
</div> </div>
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md p-4"> <div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">Prerequisites:</h4> <h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">{t('prerequisites')}</h4>
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1 list-disc list-inside mb-3"> <ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1 list-disc list-inside mb-3">
<li>Ensure you have root/sudo access on the target server</li> <li>{t('prereqRoot')}</li>
<li>Make sure curl is installed for downloading files</li> <li>{t('prereqCurl')}</li>
<li>Internet connection required for downloading script</li> <li>{t('prereqInternet')}</li>
</ul> </ul>
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">After Installation:</h4> <h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">{t('afterInstall')}</h4>
<p className="text-sm text-blue-800 dark:text-blue-200"> <p className="text-sm text-blue-800 dark:text-blue-200">
The agent will start automatically and appear in your dashboard within a few minutes. {t('agentWillStart')}
</p> </p>
</div> </div>
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button onClick={onDialogClose}> <Button onClick={onDialogClose}>
Done {t('done')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
import { Copy, Download } from "lucide-react"; import { Copy, Download } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils"; import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
interface OneClickInstallTabProps { interface OneClickInstallTabProps {
serverToken: string; serverToken: string;
@@ -26,6 +27,8 @@ export const OneClickInstallTab: React.FC<OneClickInstallTabProps> = ({
serverId, serverId,
onDialogClose, onDialogClose,
}) => { }) => {
const { t } = useLanguage();
const getOneClickInstallCommand = () => { const getOneClickInstallCommand = () => {
const scriptUrl = "https://cdn.checkcle.io/scripts/server-agent.sh"; const scriptUrl = "https://cdn.checkcle.io/scripts/server-agent.sh";
@@ -55,15 +58,15 @@ sudo -E bash ./server-agent.sh`;
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400"> <CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400">
<Download className="h-5 w-5" /> <Download className="h-5 w-5" />
One-Click Install (Recommended) {t('oneClickInstallTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-green-600 dark:text-green-300"> <CardDescription className="text-green-600 dark:text-green-300">
Copy and paste this single command to install the monitoring agent instantly {t('oneClickInstallDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-green-700 dark:text-green-400">Quick Install Command</Label> <Label className="text-green-700 dark:text-green-400">{t('quickInstallCommand')}</Label>
<div className="relative"> <div className="relative">
<pre className="bg-black-50 dark:bg-green-100/950 border border-green-200 dark:border-green-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-green-800 dark:text-green-200"> <pre className="bg-black-50 dark:bg-green-100/950 border border-green-200 dark:border-green-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-green-800 dark:text-green-200">
<code>{getOneClickInstallCommand()}</code> <code>{getOneClickInstallCommand()}</code>
@@ -76,23 +79,23 @@ sudo -E bash ./server-agent.sh`;
onClick={handleCopyCommand} onClick={handleCopyCommand}
> >
<Copy className="h-4 w-4 mr-1" /> <Copy className="h-4 w-4 mr-1" />
Copy {t('copy')}
</Button> </Button>
</div> </div>
</div> </div>
<div className="text-sm text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 p-3 rounded-md"> <div className="text-sm text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 p-3 rounded-md">
<p className="font-medium mb-1">Simply run this command on your server:</p> <p className="font-medium mb-1">{t('runCommandOnServer')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs"> <ol className="list-decimal list-inside space-y-1 text-xs">
<li>SSH into your target server</li> <li>{t('sshIntoServer')}</li>
<li>Paste and run the command above</li> <li>{t('pasteAndRun')}</li>
<li>The agent will be installed and started automatically</li> <li>{t('agentInstalled')}</li>
</ol> </ol>
</div> </div>
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button onClick={onDialogClose}> <Button onClick={onDialogClose}>
Done {t('done')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
import { copyToClipboard } from "@/utils/copyUtils"; import { copyToClipboard } from "@/utils/copyUtils";
import { OSSelector } from "./OSSelector"; import { OSSelector } from "./OSSelector";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServerAgentConfigFormProps { interface ServerAgentConfigFormProps {
formData: { formData: {
@@ -43,23 +44,25 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
isSubmitting, isSubmitting,
onSubmit, onSubmit,
}) => { }) => {
const { t } = useLanguage();
return ( return (
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="serverName">Server Name *</Label> <Label htmlFor="serverName">{t('serverName')} *</Label>
<Input <Input
id="serverName" id="serverName"
placeholder="e.g., web-server-01" placeholder={t('serverNamePlaceholder')}
value={formData.serverName} value={formData.serverName}
onChange={(e) => setFormData(prev => ({ ...prev, serverName: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, serverName: e.target.value }))}
required required
/> />
<p className="text-xs text-muted-foreground">What is the name or label used as the identifier</p> <p className="text-xs text-muted-foreground">{t('serverNameDesc')}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="serverId">Server Agent ID</Label> <Label htmlFor="serverId">{t('serverAgentId')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="serverId" id="serverId"
@@ -76,13 +79,13 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">Auto-generated unique identifier</p> <p className="text-xs text-muted-foreground">{t('serverAgentIdDesc')}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Operating System *</Label> <Label>{t('operatingSystem')} *</Label>
<OSSelector <OSSelector
value={formData.osType} value={formData.osType}
onValueChange={(value) => setFormData(prev => ({ ...prev, osType: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, osType: value }))}
@@ -91,45 +94,45 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="checkInterval">Check Interval</Label> <Label htmlFor="checkInterval">{t('checkInterval')}</Label>
<Select <Select
value={formData.checkInterval} value={formData.checkInterval}
onValueChange={(value) => setFormData(prev => ({ ...prev, checkInterval: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, checkInterval: value }))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select interval" /> <SelectValue placeholder={t('selectInterval')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="30">30 seconds</SelectItem> <SelectItem value="30">{t('interval30s')}</SelectItem>
<SelectItem value="60">1 minute</SelectItem> <SelectItem value="60">{t('interval1m')}</SelectItem>
<SelectItem value="120">2 minutes</SelectItem> <SelectItem value="120">{t('interval2m')}</SelectItem>
<SelectItem value="300">5 minutes</SelectItem> <SelectItem value="300">{t('interval5m')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground">How often to check the server and metric status</p> <p className="text-xs text-muted-foreground">{t('checkIntervalDesc')}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="retryAttempt">Retry Attempts</Label> <Label htmlFor="retryAttempt">{t('retryAttempts')}</Label>
<Select <Select
value={formData.retryAttempt} value={formData.retryAttempt}
onValueChange={(value) => setFormData(prev => ({ ...prev, retryAttempt: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, retryAttempt: value }))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select retry attempts" /> <SelectValue placeholder={t('selectRetryAttempts')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">1 attempt</SelectItem> <SelectItem value="1">{t('attempt1')}</SelectItem>
<SelectItem value="2">2 attempts</SelectItem> <SelectItem value="2">{t('attempt2')}</SelectItem>
<SelectItem value="3">3 attempts</SelectItem> <SelectItem value="3">{t('attempt3')}</SelectItem>
<SelectItem value="5">5 attempts</SelectItem> <SelectItem value="5">{t('attempt5')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground">Number of retry attempts before marking as down</p> <p className="text-xs text-muted-foreground">{t('retryAttemptsDesc')}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Server Token</Label> <Label>{t('serverToken')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input value={serverToken} readOnly className="font-mono text-sm bg-muted" /> <Input value={serverToken} readOnly className="font-mono text-sm bg-muted" />
<Button <Button
@@ -141,11 +144,11 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">Auto-generated authentication token</p> <p className="text-xs text-muted-foreground">{t('serverTokenDesc')}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>System URL</Label> <Label>{t('systemUrl')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input value={currentPocketBaseUrl} readOnly className="font-mono text-sm bg-muted" /> <Input value={currentPocketBaseUrl} readOnly className="font-mono text-sm bg-muted" />
<Button <Button
@@ -157,14 +160,14 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">Current system API URL</p> <p className="text-xs text-muted-foreground">{t('systemUrlDesc')}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="pt-4"> <div className="pt-4">
<Button type="submit" disabled={isSubmitting} className="w-full"> <Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? "Creating Agent..." : "Create Server Agent"} {isSubmitting ? t('creatingAgent') : t('createServerAgent')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -8,12 +8,14 @@ import { CPUChart } from "./charts/CPUChart";
import { MemoryChart } from "./charts/MemoryChart"; import { MemoryChart } from "./charts/MemoryChart";
import { DiskChart } from "./charts/DiskChart"; import { DiskChart } from "./charts/DiskChart";
import { NetworkChart } from "./charts/NetworkChart"; import { NetworkChart } from "./charts/NetworkChart";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServerHistoryChartsProps { interface ServerHistoryChartsProps {
serverId: string; serverId: string;
} }
export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => { export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
const { t } = useLanguage();
const { const {
timeRange, timeRange,
setTimeRange, setTimeRange,
@@ -38,10 +40,10 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
<h2 className="text-lg font-medium">Historical Performance</h2> <h2 className="text-lg font-medium">{t("historicalPerformance")}</h2>
<div className="flex items-center gap-2 ml-2"> <div className="flex items-center gap-2 ml-2">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs text-muted-foreground">Loading...</span> <span className="text-xs text-muted-foreground">{t("loading")}</span>
</div> </div>
</div> </div>
<TimeRangeSelector value={timeRange} onChange={setTimeRange} /> <TimeRangeSelector value={timeRange} onChange={setTimeRange} />
@@ -77,7 +79,7 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
<h2 className="text-lg font-medium">Historical Performance</h2> <h2 className="text-lg font-medium">{t("historicalPerformance")}</h2>
{isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />} {isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />}
</div> </div>
<TimeRangeSelector value={timeRange} onChange={setTimeRange} /> <TimeRangeSelector value={timeRange} onChange={setTimeRange} />
@@ -85,9 +87,9 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<Card> <Card>
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<p className="text-muted-foreground">Error loading chart data</p> <p className="text-muted-foreground">{t("errorLoadingChartData")}</p>
<p className="text-xs mt-2 font-mono text-red-500">{error?.message}</p> <p className="text-xs mt-2 font-mono text-red-500">{error?.message}</p>
<p className="text-xs mt-1 text-muted-foreground">Server ID: {serverId} Time Range: {timeRange}</p> <p className="text-xs mt-1 text-muted-foreground">{t("serverIdTimeRange", { serverId, timeRange })}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -101,7 +103,7 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
<h2 className="text-lg font-medium">Historical Performance</h2> <h2 className="text-lg font-medium">{t("historicalPerformance")}</h2>
{isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />} {isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />}
</div> </div>
<TimeRangeSelector value={timeRange} onChange={setTimeRange} /> <TimeRangeSelector value={timeRange} onChange={setTimeRange} />
@@ -109,11 +111,13 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<Card> <Card>
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<p className="text-muted-foreground">No historical data available for {timeRange}</p> <p className="text-muted-foreground">{t("noHistoricalData", { timeRange })}</p>
<p className="text-xs mt-2">Raw metrics count: {metrics.length}</p> <p className="text-xs mt-2">{t("rawMetricsCount", { count: metrics.length })}</p>
<p className="text-xs mt-1">Server ID: {serverId} Time Range: {timeRange}</p> <p className="text-xs mt-1">{t("serverIdTimeRange", { serverId, timeRange })}</p>
<p className="text-xs mt-1 text-muted-foreground"> <p className="text-xs mt-1 text-muted-foreground">
{metrics.length > 0 ? 'Data exists but may be outside selected time range' : 'No metrics data found'} {metrics.length > 0
? t("dataExistsOutsideRange")
: t("noMetricsDataFound")}
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -129,13 +133,13 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<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">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
<h2 className="text-lg font-medium">Historical Performance</h2> <h2 className="text-lg font-medium">{t("historicalPerformance")}</h2>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
({chartData.length} data points {timeRange}) ({chartData.length} data points {timeRange})
{isFetching && ( {isFetching && (
<span className="inline-flex items-center gap-1 ml-2"> <span className="inline-flex items-center gap-1 ml-2">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
<span className="text-blue-500">Updating...</span> <span className="text-blue-500">{t("updating")}</span>
</span> </span>
)} )}
</span> </span>
@@ -3,17 +3,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Server, Activity, AlertTriangle, Power } from "lucide-react"; import { Server, Activity, AlertTriangle, Power } from "lucide-react";
import { ServerStats } from "@/types/server.types"; import { ServerStats } from "@/types/server.types";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServerStatsCardsProps { interface ServerStatsCardsProps {
stats: ServerStats; stats: ServerStats;
} }
export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => { export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => {
const { t } = useLanguage();
const { theme } = useTheme(); const { theme } = useTheme();
const cards = [ const cards = [
{ {
title: "TOTAL SERVERS", title: t('totalServers', 'instance'),
value: stats.total, value: stats.total,
icon: Server, icon: Server,
gradient: theme === 'dark' gradient: theme === 'dark'
@@ -21,7 +23,7 @@ export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #a0522d 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #a0522d 100%)"
}, },
{ {
title: "ONLINE SERVERS", title: t('onlineServers', 'instance'),
value: stats.online, value: stats.online,
icon: Activity, icon: Activity,
gradient: theme === 'dark' gradient: theme === 'dark'
@@ -29,7 +31,7 @@ export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #66bb6a 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #66bb6a 100%)"
}, },
{ {
title: "OFFLINE SERVERS", title: t('offlineServers', 'instance'),
value: stats.offline, value: stats.offline,
icon: Power, icon: Power,
gradient: theme === 'dark' gradient: theme === 'dark'
@@ -37,7 +39,7 @@ export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ef5350 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ef5350 100%)"
}, },
{ {
title: "WARNING SERVERS", title: t('warningServers', 'instance'),
value: stats.warning, value: stats.warning,
icon: AlertTriangle, icon: AlertTriangle,
gradient: theme === 'dark' gradient: theme === 'dark'
+195 -253
View File
@@ -1,45 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
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 { Checkbox } from "@/components/ui/checkbox";
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 { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
DropdownMenu, import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
DropdownMenuContent, import { RefreshCw, Search, Eye, Activity, MoreHorizontal, Pause, Play, Edit, Trash2 } from "lucide-react";
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";
@@ -48,6 +17,7 @@ import { serverService } from "@/services/serverService";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServerTableProps { interface ServerTableProps {
servers: Server[]; servers: Server[];
@@ -55,11 +25,8 @@ interface ServerTableProps {
onRefresh: () => void; onRefresh: () => void;
} }
export const ServerTable = ({ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => {
servers, const { t } = useLanguage();
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);
@@ -67,16 +34,37 @@ export const ServerTable = ({
const [selectedServer, setSelectedServer] = useState<Server | null>(null); const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [pausingServers, setPausingServers] = useState<Set<string>>(new Set()); const [pausingServers, setPausingServers] = useState<Set<string>>(new Set());
const [selectedServerIds, setSelectedServerIds] = useState<Set<string>>(new Set());
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const filteredServers = servers.filter( const filteredServers = servers.filter(server =>
(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())
); );
const allVisibleSelected = filteredServers.length > 0 && filteredServers.every(s => selectedServerIds.has(s.id));
const someVisibleSelected = filteredServers.some(s => selectedServerIds.has(s.id)) && !allVisibleSelected;
const toggleSelectAllVisible = (checked: boolean) => {
const newSet = new Set(selectedServerIds);
if (checked) {
filteredServers.forEach(s => newSet.add(s.id));
} else {
filteredServers.forEach(s => newSet.delete(s.id));
}
setSelectedServerIds(newSet);
};
const toggleSelectOne = (serverId: string, checked: boolean) => {
const newSet = new Set(selectedServerIds);
if (checked) newSet.add(serverId); else newSet.delete(serverId);
setSelectedServerIds(newSet);
};
const handleViewDetails = (serverId: string) => { const handleViewDetails = (serverId: string) => {
navigate(`/server-detail/${serverId}`); navigate(`/server-detail/${serverId}`);
}; };
@@ -94,38 +82,35 @@ export const ServerTable = ({
} }
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 ? t('serverResumed') : t('serverPaused'),
description: `Monitoring ${isPaused ? "resumed" : "paused"} for ${ description: isPaused ? t('monitoringResumed', { name: server.name }) : t('monitoringPaused', { name: server.name }),
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: t('error'),
description: `Failed to ${ description: isPaused ? t('resumeServerError') : t('pauseServerError'),
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;
@@ -150,7 +135,7 @@ export const ServerTable = ({
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",
@@ -163,6 +148,7 @@ export const ServerTable = ({
// 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({
@@ -175,65 +161,92 @@ export const ServerTable = ({
} }
}; };
const confirmBulkDelete = async () => {
if (selectedServerIds.size === 0 || isBulkDeleting) return;
try {
setIsBulkDeleting(true);
const ids = Array.from(selectedServerIds);
const deletions = ids.map(id => pb.collection('servers').delete(id));
const results = await Promise.allSettled(deletions);
const failed = results.filter(r => r.status === 'rejected').length;
if (failed === 0) {
toast({ title: "Servers deleted", description: `${ids.length} server(s) have been deleted.` });
} else if (failed === ids.length) {
toast({ variant: "destructive", title: "Error", description: "Failed to delete selected servers. Please try again." });
} else {
toast({ variant: "destructive", title: "Partial success", description: `Deleted ${ids.length - failed}, failed ${failed}.` });
}
onRefresh();
setSelectedServerIds(new Set());
setBulkDeleteDialogOpen(false);
} catch (_e) {
toast({ variant: "destructive", title: "Error", description: "Failed to delete selected servers. Please try again." });
} finally {
setIsBulkDeleting(false);
}
};
const CustomProgressBar = ({ const CustomProgressBar = ({
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] -mt-20"> <div className="space-y-2 min-w-[120px]">
<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">{subtitle} </span> <span className="text-xs text-muted-foreground">
{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( className={`h-full bg-gradient-to-r ${getGradientColors(type, value)} rounded-full transition-all duration-700 ease-out relative overflow-hidden`}
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>
); );
@@ -243,12 +256,12 @@ export const ServerTable = ({
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>{t('servers')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex items-center justify-center"> <CardContent className="flex-1 flex items-center justify-center">
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<RefreshCw className="h-6 w-6 animate-spin" /> <RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2">Loading servers...</span> <span className="ml-2">{t('loadingServers')}</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -260,17 +273,29 @@ export const ServerTable = ({
<Card className="bg-transparent border-0 shadow-none"> <Card className="bg-transparent border-0 shadow-none">
<CardHeader className="pb-4 px-0"> <CardHeader className="pb-4 px-0">
<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">
<CardTitle className="text-xl font-semibold">Servers</CardTitle> <CardTitle className="text-xl font-semibold">{t('servers')}</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1 sm:w-64"> <div className="relative flex-1 sm:w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search servers..." placeholder={t('searchServersPlaceholder')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8" className="pl-8"
/> />
</div> </div>
{selectedServerIds.size > 0 && (
<div className="hidden sm:block text-sm text-muted-foreground mr-2">
{selectedServerIds.size} selected
</div>
)}
<Button
onClick={() => setBulkDeleteDialogOpen(true)}
variant="destructive"
disabled={selectedServerIds.size === 0}
>
Delete Selected
</Button>
<Button onClick={onRefresh} variant="outline" size="icon"> <Button onClick={onRefresh} variant="outline" size="icon">
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
@@ -280,119 +305,57 @@ export const ServerTable = ({
<CardContent className="p-0"> <CardContent className="p-0">
{filteredServers.length === 0 ? ( {filteredServers.length === 0 ? (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">No servers found</p> <p className="text-muted-foreground">{t('noServersFound')}</p>
</div> </div>
) : ( ) : (
<div <div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
className={`${
theme === "dark" ? "bg-gray-900" : "bg-white"
} rounded-lg border border-border shadow-sm`}
>
<Table> <Table>
<TableHeader <TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
className={`${ <TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
theme === "dark" ? "bg-gray-800" : "bg-gray-50" <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} w-10`}>
}`} <div onClick={(e) => e.stopPropagation()}>
> <Checkbox
<TableRow checked={allVisibleSelected}
className={`${ onCheckedChange={(v) => toggleSelectAllVisible(Boolean(v))}
theme === "dark" aria-label="Select all"
? "border-gray-700 hover:bg-gray-800" indeterminate={someVisibleSelected}
: "border-gray-200 hover:bg-gray-100" />
}`} </div>
>
<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> </TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('name')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('status')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('OS')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('IPAddress')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('CPU')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('memory')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('disk')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('uptime')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('lastChecked')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right`}>{t('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 = const memoryUsage = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 0;
server.ram_total > 0 const diskUsage = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 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);
const isSelected = selectedServerIds.has(server.id);
return ( return (
<TableRow <TableRow
key={server.id} key={server.id}
className="hover:bg-muted/50 cursor-pointer" className={`hover:bg-muted/50 cursor-pointer ${isSelected ? 'bg-muted/30' : ''}`}
onClick={() => handleViewDetails(server.id)} onClick={() => handleViewDetails(server.id)}
> >
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={(v) => toggleSelectOne(server.id, Boolean(v))}
aria-label={`Select ${server.name}`}
/>
</TableCell>
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="truncate" title={server.name}> <div className="truncate" title={server.name}>
{server.name} {server.name}
@@ -404,10 +367,7 @@ export const ServerTable = ({
<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 <span className="text-sm truncate" title={server.os_type}>
className="text-sm truncate"
title={server.os_type}
>
{server.os_type} {server.os_type}
</span> </span>
</div> </div>
@@ -429,9 +389,7 @@ export const ServerTable = ({
<CustomProgressBar <CustomProgressBar
value={memoryUsage} value={memoryUsage}
label={`${memoryUsage.toFixed(1)}%`} label={`${memoryUsage.toFixed(1)}%`}
subtitle={serverService.formatBytes( subtitle={serverService.formatBytes(server.ram_total)}
server.ram_total
)}
type="memory" type="memory"
/> />
</TableCell> </TableCell>
@@ -439,17 +397,12 @@ export const ServerTable = ({
<CustomProgressBar <CustomProgressBar
value={diskUsage} value={diskUsage}
label={`${diskUsage.toFixed(1)}%`} label={`${diskUsage.toFixed(1)}%`}
subtitle={serverService.formatBytes( subtitle={serverService.formatBytes(server.disk_total)}
server.disk_total
)}
type="disk" type="disk"
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
<div <div className="text-sm truncate" title={server.uptime}>
className="text-sm truncate"
title={server.uptime}
>
{server.uptime} {server.uptime}
</div> </div>
</TableCell> </TableCell>
@@ -458,18 +411,11 @@ export const ServerTable = ({
{new Date(server.last_checked).toLocaleString()} {new Date(server.last_checked).toLocaleString()}
</div> </div>
</TableCell> </TableCell>
<TableCell <TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
className="text-right"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="ghost" className="h-8 w-8 p-0" disabled={isProcessing}>
variant="ghost" <span className="sr-only">{t('openMenu')}</span>
className="h-8 w-8 p-0"
disabled={isProcessing}
>
<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" />
) : ( ) : (
@@ -477,69 +423,45 @@ export const ServerTable = ({
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent align="end" className="w-[200px]">
align="end" <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(server.id); }}>
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 {t('viewServerDetail')}
</DropdownMenuItem> </DropdownMenuItem>
{server.docker === "true" && ( {server.docker === 'true' && (
<DropdownMenuItem <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewContainers(server.id); }}>
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 {t('containerMonitoring')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => { e.stopPropagation(); handlePauseResume(server); }}
e.stopPropagation();
handlePauseResume(server);
}}
disabled={isProcessing} disabled={isProcessing}
> >
{isPaused ? ( {isPaused ? (
<> <>
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
Resume Monitoring {t('resumeMonitoring')}
</> </>
) : ( ) : (
<> <>
<Pause className="mr-2 h-4 w-4" /> <Pause className="mr-2 h-4 w-4" />
Pause Monitoring {t('pauseMonitoring')}
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleEdit(server); }}>
onClick={(e) => {
e.stopPropagation();
handleEdit(server);
}}
>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Server {t('editServer')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => { e.stopPropagation(); handleDelete(server); }}
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" />
Delete Server {t('deleteServer')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -566,25 +488,45 @@ export const ServerTable = ({
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>{t('deleteServerConfirmTitle')}</AlertDialogTitle>
Are you sure you want to delete this server?
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete{" "} {t('deleteServerConfirmDesc').replace('{name}', selectedServer?.name ?? '')}
<span className="font-semibold text-foreground">
{selectedServer?.name}
</span>{" "}
and all of its monitoring data.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={isDeleting}>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDelete} onClick={confirmDelete}
disabled={isDeleting} disabled={isDeleting}
className="bg-red-600 text-white hover:bg-red-700" className="bg-red-600 text-white hover:bg-red-700"
> >
{isDeleting ? "Deleting..." : "Delete"} {isDeleting ? t('deleting') : t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation Dialog */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete selected servers?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete {selectedServerIds.size} server(s) and all of their monitoring data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBulkDeleting}>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmBulkDelete}
disabled={isBulkDeleting}
className="bg-red-600 text-white hover:bg-red-700"
>
{isBulkDeleting ? t('deleting') : 'Delete Selected'}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -1,4 +1,3 @@
import React from "react"; import React from "react";
import { Sidebar } from "@/components/dashboard/Sidebar"; import { Sidebar } from "@/components/dashboard/Sidebar";
import { Header } from "@/components/dashboard/Header"; import { Header } from "@/components/dashboard/Header";
@@ -7,11 +6,9 @@ import { ServiceNotFound } from "@/components/services/ServiceNotFound";
import { Service } from "@/types/service.types"; import { Service } from "@/types/service.types";
interface ServiceDetailWrapperProps { interface ServiceDetailWrapperProps {
children: React.ReactNode; children?: React.ReactNode;
isLoading: boolean; isLoading: boolean;
service: Service | null; service: Service | null;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
currentUser: any; currentUser: any;
handleLogout: () => void; handleLogout: () => void;
} }
@@ -20,20 +17,16 @@ export const ServiceDetailWrapper = ({
children, children,
isLoading, isLoading,
service, service,
sidebarCollapsed,
toggleSidebar,
currentUser, currentUser,
handleLogout handleLogout
}: ServiceDetailWrapperProps) => { }: ServiceDetailWrapperProps) => {
return ( return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground"> <div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} /> <Sidebar />
<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}
toggleSidebar={toggleSidebar}
/> />
{isLoading ? ( {isLoading ? (
@@ -1,78 +1,34 @@
import { useState, useEffect, useCallback } from "react"; import React, { useState, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { DateRangeOption } from "../DateRangeFilter"; import { DateRangeOption } from "../DateRangeFilter";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { ServiceDetailContent } from "../ServiceDetailContent"; import { ServiceDetailContent } from "../ServiceDetailContent";
import { ServiceDetailWrapper } from "./ServiceDetailWrapper"; import { ServiceDetailWrapper } from "./ServiceDetailWrapper";
import { useServiceData, useRealTimeUpdates } from "./hooks"; import { useServiceData, useRealTimeUpdates } from "./hooks";
import { toast } from "@/components/ui/use-toast";
export const ServiceDetailContainer = () => { export const ServiceDetailContainer = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
// Set default to 24h
const [startDate, setStartDate] = useState<Date>(() => { const [startDate, setStartDate] = useState<Date>(() => {
const date = new Date(); const date = new Date();
date.setHours(date.getHours() - 24); // Go back 24 hours date.setHours(date.getHours() - 24);
return date; return date;
}); });
const [endDate, setEndDate] = useState<Date>(() => { const [endDate, setEndDate] = useState<Date>(() => {
const date = new Date(); const date = new Date();
date.setMinutes(date.getMinutes() + 5); // Add 5 minutes buffer to future date.setMinutes(date.getMinutes() + 5);
return date; return date;
}); });
const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h'); const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h');
// State for sidebar collapse functionality (shared with Dashboard)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
// Check if there's a saved preference in localStorage
const saved = localStorage.getItem("sidebarCollapsed");
return saved ? JSON.parse(saved) : window.innerWidth < 768;
});
// Toggle sidebar and save preference
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(prev => {
const newState = !prev;
localStorage.setItem("sidebarCollapsed", JSON.stringify(newState));
return newState;
});
}, []);
// Get current user for header
const currentUser = authService.getCurrentUser(); const currentUser = authService.getCurrentUser();
useEffect(() => {
// Verify user is authenticated
if (!authService.isAuthenticated()) {
toast({
variant: "destructive",
title: "Authentication required",
description: "Please log in to view service details",
});
navigate("/login");
}
// Auto-collapse sidebar on small screens
const handleResize = () => {
if (window.innerWidth < 768 && !sidebarCollapsed) {
setSidebarCollapsed(true);
localStorage.setItem("sidebarCollapsed", JSON.stringify(true));
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [navigate, sidebarCollapsed]);
// Handler for logout (same as Dashboard)
const handleLogout = () => { const handleLogout = () => {
authService.logout(); authService.logout();
navigate("/login"); navigate("/login");
}; };
// Use our custom hooks
const { const {
service, service,
uptimeData, uptimeData,
@@ -85,7 +41,6 @@ export const ServiceDetailContainer = () => {
handleRegionalAgentChange handleRegionalAgentChange
} = useServiceData(id, startDate, endDate); } = useServiceData(id, startDate, endDate);
// Set up real-time updates
useRealTimeUpdates({ useRealTimeUpdates({
serviceId: id, serviceId: id,
startDate, startDate,
@@ -94,18 +49,12 @@ export const ServiceDetailContainer = () => {
setUptimeData setUptimeData
}); });
// Handle date range filter changes
const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => { const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => {
console.log(`ServiceDetailContainer: Date range changed: ${start.toISOString()} to ${end.toISOString()}, option: ${option}`);
// Update state which will trigger the useEffect in useServiceData
setStartDate(start); setStartDate(start);
setEndDate(end); setEndDate(end);
setSelectedRange(option); setSelectedRange(option);
// Also explicitly fetch data with the new range to ensure immediate update
if (id) { if (id) {
console.log(`ServiceDetailContainer: Explicitly fetching data for service ${id} with new range`);
fetchUptimeData(id, start, end, option, selectedRegionalAgent); fetchUptimeData(id, start, end, option, selectedRegionalAgent);
} }
}, [id, fetchUptimeData, selectedRegionalAgent]); }, [id, fetchUptimeData, selectedRegionalAgent]);
@@ -114,12 +63,10 @@ export const ServiceDetailContainer = () => {
<ServiceDetailWrapper <ServiceDetailWrapper
isLoading={isLoading} isLoading={isLoading}
service={service} service={service}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
currentUser={currentUser} currentUser={currentUser}
handleLogout={handleLogout} handleLogout={handleLogout}
> >
{service && ( {service ? (
<ServiceDetailContent <ServiceDetailContent
service={service} service={service}
uptimeData={uptimeData} uptimeData={uptimeData}
@@ -129,7 +76,7 @@ export const ServiceDetailContainer = () => {
selectedRegionalAgent={selectedRegionalAgent} selectedRegionalAgent={selectedRegionalAgent}
onRegionalAgentChange={handleRegionalAgentChange} onRegionalAgentChange={handleRegionalAgentChange}
/> />
)} ) : null}
</ServiceDetailWrapper> </ServiceDetailWrapper>
); );
}; };
@@ -63,7 +63,7 @@ export function ServicesPagination({
return ( return (
<div className="flex items-center justify-between py-4 px-4 border-t border-border"> <div className="flex items-center justify-between py-4 px-4 border-t border-border">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground whitespace-nowrap">
{t("rowsPerPage") ?? "Rows per page"}: {t("rowsPerPage") ?? "Rows per page"}:
</span> </span>
<Select <Select
@@ -25,6 +25,10 @@ export const useServiceSchema = () => {
type: z.enum(["http", "ping", "tcp", "dns"]), type: z.enum(["http", "ping", "tcp", "dns"]),
url: z.string() url: z.string()
.min(1, t("urlDomainHostRequired")) .min(1, t("urlDomainHostRequired"))
.refine(
(value) => !/\s/.test(value),
t("spacesNotAllowed")
)
.refine( .refine(
(value) => value.trim().length > 0, (value) => value.trim().length > 0,
t("enterValidUrlHostnameDomain") t("enterValidUrlHostnameDomain")
@@ -158,14 +158,14 @@ const DataRetentionSettings = () => {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" /> <Database className="h-5 w-5" />
{t("dataRetention", "settings")} {t("dataRetention")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<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">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. <span className="font-medium">{t("permissionNotice")}</span> {t("permissionNoticeDataRetention")}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</CardContent> </CardContent>
@@ -178,7 +178,7 @@ const DataRetentionSettings = () => {
return ( return (
<div className="p-4 flex items-center justify-center"> <div className="p-4 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin mr-2" /> <Loader2 className="h-6 w-6 animate-spin mr-2" />
Loading retention settings... {t("loadingRetentionSettings")}
</div> </div>
); );
} }
@@ -189,16 +189,16 @@ const DataRetentionSettings = () => {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" /> <Database className="h-5 w-5" />
Data Retention Settings {t("dataRetention")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Configure how long monitoring data is kept in the system {t("dataRetentionDescription")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="uptimeRetention">Uptime Monitoring Retention (days)</Label> <Label htmlFor="uptimeRetention">{t("uptimeRetentionLabel")}</Label>
<Input <Input
id="uptimeRetention" id="uptimeRetention"
type="number" type="number"
@@ -218,12 +218,12 @@ const DataRetentionSettings = () => {
className="mt-1" className="mt-1"
/> />
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Service uptime and incident data older than this will be automatically deleted {t("uptimeRetentionHelp")}
</p> </p>
</div> </div>
<div> <div>
<Label htmlFor="serverRetention">Server Monitoring Retention (days)</Label> <Label htmlFor="serverRetention">{t("serverRetentionLabel")}</Label>
<Input <Input
id="serverRetention" id="serverRetention"
type="number" type="number"
@@ -243,7 +243,7 @@ const DataRetentionSettings = () => {
className="mt-1" className="mt-1"
/> />
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Server metrics and process data older than this will be automatically deleted {t("serverRetentionHelp")}
</p> </p>
</div> </div>
</div> </div>
@@ -252,7 +252,7 @@ const DataRetentionSettings = () => {
<Alert> <Alert>
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
<AlertDescription> <AlertDescription>
Last automatic cleanup: {new Date(lastCleanup).toLocaleString()} {t("lastCleanup")}: {new Date(lastCleanup).toLocaleString()}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@@ -266,7 +266,7 @@ const DataRetentionSettings = () => {
{isSaving ? ( {isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : null} ) : null}
Save Changes {t("save")}
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
@@ -38,7 +38,7 @@ interface NotificationChannelDialogProps {
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", "notifiarr", "gotify", "webhook"]), notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "notifiarr", "gotify", "webhook", "matrix"]),
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(),
@@ -110,6 +110,13 @@ const gotifySchema = baseSchema.extend({
server_url: z.string().url("Must be a valid server URL"), server_url: z.string().url("Must be a valid server URL"),
}); });
const matrixSchema = baseSchema.extend({
notification_type: z.literal("matrix"),
matrix_homeserver: z.string().url("Must be a valid homeserver URL"),
matrix_room_id: z.string().min(1, "Room ID is required"),
matrix_access_token: z.string().min(1, "Access token is required"),
});
const formSchema = z.discriminatedUnion("notification_type", [ const formSchema = z.discriminatedUnion("notification_type", [
telegramSchema, telegramSchema,
discordSchema, discordSchema,
@@ -122,6 +129,7 @@ const formSchema = z.discriminatedUnion("notification_type", [
notifiarrSchema, notifiarrSchema,
gotifySchema, gotifySchema,
webhookSchema, webhookSchema,
matrixSchema,
]); ]);
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -194,6 +202,12 @@ const notificationTypeOptions = [
description: "Send notifications to custom webhook", description: "Send notifications to custom webhook",
icon: "/upload/notification/webhook.png" icon: "/upload/notification/webhook.png"
}, },
{
value: "matrix",
label: "Matrix",
description: "Send notifications to a Matrix chat room",
icon: "/upload/notification/matrix.png"
},
]; ];
const webhookPayloadTemplates = { const webhookPayloadTemplates = {
@@ -247,6 +261,7 @@ export const NotificationChannelDialog = ({
onClose, onClose,
editingConfig editingConfig
}: NotificationChannelDialogProps) => { }: NotificationChannelDialogProps) => {
const { t } = useLanguage();
const isEditing = !!editingConfig; const isEditing = !!editingConfig;
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -325,7 +340,7 @@ export const NotificationChannelDialog = ({
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isEditing ? "Edit Notification Channel" : "Add Notification Channel"} {isEditing ? t("editChannel") : t("addChannelDialog")}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -336,12 +351,12 @@ export const NotificationChannelDialog = ({
name="notify_name" name="notify_name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Channel Name</FormLabel> <FormLabel>{t("channelName")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="My Notification Channel" {...field} /> <Input placeholder={t("channelNamePlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
A name to identify this notification channel {t("channelNameDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -353,11 +368,11 @@ export const NotificationChannelDialog = ({
name="notification_type" name="notification_type"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Channel Type</FormLabel> <FormLabel>{t("channelType")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}> <Select onValueChange={field.onChange} value={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select notification type" /> <SelectValue placeholder={t("selectType")} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
@@ -370,8 +385,8 @@ export const NotificationChannelDialog = ({
className="w-5 h-5 object-contain" className="w-5 h-5 object-contain"
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{option.label}</span> <span className="font-medium">{t(option.value)}</span>
<span className="text-xs text-muted-foreground">{option.description}</span> <span className="text-xs text-muted-foreground">{t(option.description)}</span>
</div> </div>
</div> </div>
</SelectItem> </SelectItem>
@@ -390,12 +405,12 @@ export const NotificationChannelDialog = ({
name="telegram_chat_id" name="telegram_chat_id"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Chat ID</FormLabel> <FormLabel>{t("telegramChatId")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Telegram Chat ID" {...field} /> <Input placeholder={t("telegramChatIdPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The Telegram chat ID to send notifications to {t("telegramChatIdDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -406,12 +421,12 @@ export const NotificationChannelDialog = ({
name="bot_token" name="bot_token"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Bot Token</FormLabel> <FormLabel>{t("botToken")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Telegram Bot Token" {...field} type="password" /> <Input placeholder={t("botTokenPlaceholder")} {...field} type="password" />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your Telegram bot token from @BotFather {t("botTokenDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -426,12 +441,12 @@ export const NotificationChannelDialog = ({
name="discord_webhook_url" name="discord_webhook_url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Webhook URL</FormLabel> <FormLabel>{t("discordWebhookUrl")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://discord.com/api/webhooks/..." {...field} /> <Input placeholder={t("discordWebhookUrlPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Discord webhook URL from your server settings {t("discordWebhookUrlDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -445,12 +460,12 @@ export const NotificationChannelDialog = ({
name="slack_webhook_url" name="slack_webhook_url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Webhook URL</FormLabel> <FormLabel>{t("slackWebhookUrl")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://hooks.slack.com/services/..." {...field} /> <Input placeholder={t("slackWebhookUrlPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Slack incoming webhook URL {t("slackWebhookUrlDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -465,12 +480,12 @@ export const NotificationChannelDialog = ({
name="signal_number" name="signal_number"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Signal Number</FormLabel> <FormLabel>{t("signalNumber")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="+1234567890" {...field} /> <Input placeholder={t("signalNumberPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Signal phone number to send notifications to {t("signalNumberDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -481,12 +496,12 @@ export const NotificationChannelDialog = ({
name="signal_api_endpoint" name="signal_api_endpoint"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Signal API Endpoint</FormLabel> <FormLabel>{t("signalApiEndpoint")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://your-signal-api.com/v2/send" {...field} /> <Input placeholder={t("signalApiEndpointPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The Rest API endpoint for your Signal service {t("signalApiEndpointDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -501,12 +516,12 @@ export const NotificationChannelDialog = ({
name="google_chat_webhook_url" name="google_chat_webhook_url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Google Chat Webhook URL</FormLabel> <FormLabel>{t("googleChatWebhookUrl")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://chat.googleapis.com/v1/spaces/..." {...field} /> <Input placeholder={t("googleChatWebhookUrlPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Google Chat webhook URL from your Google Chat space {t("googleChatWebhookUrlDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -521,12 +536,12 @@ export const NotificationChannelDialog = ({
name="email_address" name="email_address"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Email Address</FormLabel> <FormLabel>{t("emailAddress")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="notifications@example.com" {...field} type="email" /> <Input placeholder={t("emailAddressPlaceholder")} {...field} type="email" />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Email address to send notifications to {t("emailAddressDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -537,12 +552,12 @@ export const NotificationChannelDialog = ({
name="email_sender_name" name="email_sender_name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Sender Name</FormLabel> <FormLabel>{t("emailSenderName")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Alert System" {...field} /> <Input placeholder={t("emailSenderNamePlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Display name for outgoing emails {t("emailSenderNameDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -554,9 +569,9 @@ export const NotificationChannelDialog = ({
name="smtp_server" name="smtp_server"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>SMTP Server</FormLabel> <FormLabel>{t("smtpServer")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="smtp.gmail.com" {...field} /> <Input placeholder={t("smtpServerPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -567,9 +582,9 @@ export const NotificationChannelDialog = ({
name="smtp_port" name="smtp_port"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>SMTP Port</FormLabel> <FormLabel>{t("smtpPort")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="587" {...field} /> <Input placeholder={t("smtpPortPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -581,12 +596,12 @@ export const NotificationChannelDialog = ({
name="smtp_password" name="smtp_password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>SMTP Password</FormLabel> <FormLabel>{t("smtpPassword")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Enter your SMTP password" {...field} type="password" /> <Input placeholder={t("smtpPasswordPlaceholder")} {...field} type="password" />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Password for authenticating with the SMTP server {t("smtpPasswordDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -602,12 +617,12 @@ export const NotificationChannelDialog = ({
name="ntfy_endpoint" name="ntfy_endpoint"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>NTFY Endpoint</FormLabel> <FormLabel>{t("ntfyEndpoint")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://ntfy.sh/your-topic" {...field} /> <Input placeholder={t("ntfyEndpointPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The NTFY endpoint URL including your topic (e.g., https://ntfy.sh/checkcle) {t("ntfyEndpointDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -618,12 +633,12 @@ export const NotificationChannelDialog = ({
name="api_token" name="api_token"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>API Token (Optional)</FormLabel> <FormLabel>{t("apiTokenOptional")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Enter NTFY API token" {...field} type="password" /> <Input placeholder={t("apiTokenPlaceholder")} {...field} type="password" />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Optional API token for authentication with NTFY server {t("apiTokenDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -639,12 +654,12 @@ export const NotificationChannelDialog = ({
name="api_token" name="api_token"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>API Token</FormLabel> <FormLabel>{t("apiToken")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Your Pushover API token" {...field} type="password" /> <Input placeholder={t("apiTokenPlaceholder")} {...field} type="password" />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your Pushover application API token {t("apiTokenDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -655,12 +670,12 @@ export const NotificationChannelDialog = ({
name="user_key" name="user_key"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>User Key</FormLabel> <FormLabel>{t("pushoverUserKey")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Your Pushover user key" {...field} /> <Input placeholder={t("pushoverUserKeyPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your Pushover user key (or group key) {t("pushoverUserKeyDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -676,12 +691,12 @@ export const NotificationChannelDialog = ({
name="api_token" name="api_token"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>API Token</FormLabel> <FormLabel>{t("apiToken")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Your Notifiarr API token" {...field} type="password" /> <Input placeholder={t("apiTokenPlaceholder")} {...field} type="password" />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your Notifiarr API token for sending notifications {t("apiTokenDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -692,12 +707,12 @@ export const NotificationChannelDialog = ({
name="channel_id" name="channel_id"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Channel ID</FormLabel> <FormLabel>{t("notifiarrChannelId")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Discord Channel ID" {...field} /> <Input placeholder={t("notifiarrChannelIdPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The Discord channel ID where notifications will be sent {t("notifiarrChannelIdDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -713,12 +728,12 @@ export const NotificationChannelDialog = ({
name="api_token" name="api_token"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>API Token</FormLabel> <FormLabel>{t("apiToken")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Your Gotify API token" {...field} type="password" /> <Input placeholder={t("apiTokenPlaceholder")} {...field} type="password" />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your Gotify application API token {t("apiTokenDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -729,12 +744,12 @@ export const NotificationChannelDialog = ({
name="server_url" name="server_url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Server URL</FormLabel> <FormLabel>{t("gotifyServerUrl")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://your-gotify-server.com" {...field} /> <Input placeholder={t("gotifyServerUrlPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The URL of your Gotify server {t("gotifyServerUrlDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -750,12 +765,12 @@ export const NotificationChannelDialog = ({
name="webhook_url" name="webhook_url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Webhook URL</FormLabel> <FormLabel>{t("webhookUrl")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://api.example.com/webhook" {...field} /> <Input placeholder={t("webhookUrlPlaceholder")} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The URL where webhook notifications will be sent {t("webhookUrlDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -768,10 +783,9 @@ export const NotificationChannelDialog = ({
name="webhook_payload_template" name="webhook_payload_template"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Payload Template (Optional)</FormLabel> <FormLabel>{t("payloadTemplate")}</FormLabel>
<FormDescription> <FormDescription>
JSON template for the webhook payload. Leave empty to use default template. {t("payloadTemplateDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -780,15 +794,15 @@ export const NotificationChannelDialog = ({
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Payload Templates</CardTitle> <CardTitle className="text-sm font-medium">{t("payloadTemplates")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
<div className="mt-4"> <div className="mt-4">
<h4 className="text-sm font-medium mb-2">Available Placeholders:</h4> <h4 className="text-sm font-medium mb-2">{t("availablePlaceholders")}</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium text-muted-foreground">Server:</p> <p className="font-medium text-muted-foreground">{t("server")}</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{server_name}'}</code><br/> <code className="bg-muted px-1 rounded">${'{server_name}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{cpu_usage}'}</code><br/> <code className="bg-muted px-1 rounded">${'{cpu_usage}'}</code><br/>
@@ -797,7 +811,7 @@ export const NotificationChannelDialog = ({
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium text-muted-foreground">Service:</p> <p className="font-medium text-muted-foreground">{t("service")}</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{service_name}'}</code><br/> <code className="bg-muted px-1 rounded">${'{service_name}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{response_time}'}</code><br/> <code className="bg-muted px-1 rounded">${'{response_time}'}</code><br/>
@@ -806,7 +820,7 @@ export const NotificationChannelDialog = ({
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium text-muted-foreground">SSL:</p> <p className="font-medium text-muted-foreground">{t("ssl")}</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{domain}'}</code><br/> <code className="bg-muted px-1 rounded">${'{domain}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{expiry_date}'}</code><br/> <code className="bg-muted px-1 rounded">${'{expiry_date}'}</code><br/>
@@ -815,7 +829,7 @@ export const NotificationChannelDialog = ({
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium text-muted-foreground">Common:</p> <p className="font-medium text-muted-foreground">{t("common")}</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{status}'}</code><br/> <code className="bg-muted px-1 rounded">${'{status}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{time}'}</code><br/> <code className="bg-muted px-1 rounded">${'{time}'}</code><br/>
@@ -832,15 +846,68 @@ export const NotificationChannelDialog = ({
</> </>
)} )}
{notificationType === "matrix" && (
<>
<FormField
control={form.control}
name="matrix_homeserver"
render={({ field }) => (
<FormItem>
<FormLabel>{t("matrixHomeserver")}</FormLabel>
<FormControl>
<Input placeholder={t("matrixHomeserverPlaceholder")} {...field} />
</FormControl>
<FormDescription>
{t("matrixHomeserverDesc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="matrix_room_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("matrixRoomId")}</FormLabel>
<FormControl>
<Input placeholder={t("matrixRoomIdPlaceholder")} {...field} />
</FormControl>
<FormDescription>
{t("matrixRoomIdDesc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="matrix_access_token"
render={({ field }) => (
<FormItem>
<FormLabel>{t("matrixAccessToken")}</FormLabel>
<FormControl>
<Input placeholder={t("matrixAccessTokenPlaceholder")} {...field} type="password" />
</FormControl>
<FormDescription>
{t("matrixAccessTokenDesc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField <FormField
control={form.control} control={form.control}
name="enabled" name="enabled"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Enabled</FormLabel> <FormLabel>{t("enabled")}</FormLabel>
<FormDescription> <FormDescription>
Enable or disable this notification channel {t("enabledDesc")}
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -855,11 +922,11 @@ export const NotificationChannelDialog = ({
<DialogFooter> <DialogFooter>
<Button variant="outline" type="button" onClick={handleClose}> <Button variant="outline" type="button" onClick={handleClose}>
Cancel {t("cancel")}
</Button> </Button>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? "Update Channel" : "Create Channel"} {isEditing ? t("updateChannel") : t("createChannel")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -1,6 +1,7 @@
import { AlertConfiguration } from "@/services/alertConfigService"; import { AlertConfiguration } from "@/services/alertConfigService";
import { Bell, Edit, Trash2 } from "lucide-react"; import { Bell, Edit, Trash2 } from "lucide-react";
import { useState } from "react";
import { import {
Table, Table,
TableBody, TableBody,
@@ -33,29 +34,40 @@ export const NotificationChannelList = ({
onEdit, onEdit,
onDelete onDelete
}: NotificationChannelListProps) => { }: NotificationChannelListProps) => {
const [optimisticStates, setOptimisticStates] = useState<Record<string, boolean>>({});
const toggleEnabled = async (config: CombinedChannel) => { const toggleEnabled = async (config: CombinedChannel) => {
if (!config.id) return; if (!config.id) return;
const currentEnabled = typeof config.enabled === 'string'
? config.enabled === "true" || config.enabled === "on"
: !!config.enabled;
// Apply optimistic update immediately
setOptimisticStates(prev => ({
...prev,
[config.id!]: !currentEnabled
}));
try {
if (config.isWebhook) { if (config.isWebhook) {
// Handle webhook toggle // Handle webhook toggle
try { const newEnabled = currentEnabled ? "off" : "on";
const newEnabled = config.enabled ? "off" : "on";
await pb.collection('webhook').update(config.id, { await pb.collection('webhook').update(config.id, {
enabled: newEnabled enabled: newEnabled
}); });
// Trigger refresh by calling onEdit with empty config
onEdit({} as AlertConfiguration);
} catch (error) {
console.error("Error updating webhook:", error);
}
} else { } else {
// Handle alert config toggle // Handle alert config toggle
await alertConfigService.updateAlertConfiguration(config.id, { await alertConfigService.updateAlertConfiguration(config.id, {
enabled: !config.enabled enabled: !currentEnabled
}); });
}
// The parent component will refresh the list } catch (error) {
onEdit(config as AlertConfiguration); setOptimisticStates(prev => ({
...prev,
[config.id!]: currentEnabled
}));
} }
}; };
@@ -70,6 +82,7 @@ export const NotificationChannelList = ({
case "pushover": return "Pushover"; case "pushover": return "Pushover";
case "notifiarr": return "Notifiarr"; case "notifiarr": return "Notifiarr";
case "webhook": return "Webhook"; case "webhook": return "Webhook";
case "matrix": return "Matrix";
default: return type || "Unknown"; default: return type || "Unknown";
} }
}; };
@@ -90,6 +103,8 @@ export const NotificationChannelList = ({
return config.signal_number || ''; return config.signal_number || '';
case "email": case "email":
return config.email_address || ''; return config.email_address || '';
case "matrix":
return config.matrix_room_id || '';
default: default:
return ''; return '';
} }
@@ -133,7 +148,9 @@ export const NotificationChannelList = ({
<TableCell> <TableCell>
<Switch <Switch
checked={ checked={
typeof channel.enabled === 'string' channel.id && optimisticStates.hasOwnProperty(channel.id)
? optimisticStates[channel.id]
: typeof channel.enabled === 'string'
? channel.enabled === "true" || channel.enabled === "on" ? channel.enabled === "true" || channel.enabled === "on"
: !!channel.enabled : !!channel.enabled
} }
@@ -149,7 +166,7 @@ export const NotificationChannelList = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onEdit(channel as AlertConfiguration)} onClick={() => onEdit(channel as AlertConfiguration)}
disabled={channel.isWebhook} // Disable edit for webhooks for now disabled={channel.isWebhook}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
@@ -9,6 +9,7 @@ import { WebhookConfiguration, webhookService } from "@/services/webhookService"
import { NotificationChannelDialog } from "./NotificationChannelDialog"; import { NotificationChannelDialog } from "./NotificationChannelDialog";
import { NotificationChannelList } from "./NotificationChannelList"; import { NotificationChannelList } from "./NotificationChannelList";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { useLanguage } from "@/contexts/LanguageContext";
interface CombinedChannel extends Partial<AlertConfiguration> { interface CombinedChannel extends Partial<AlertConfiguration> {
isWebhook?: boolean; isWebhook?: boolean;
@@ -18,6 +19,7 @@ interface CombinedChannel extends Partial<AlertConfiguration> {
} }
const NotificationSettings = () => { const NotificationSettings = () => {
const { t } = useLanguage();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]); const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [webhookConfigs, setWebhookConfigs] = useState<WebhookConfiguration[]>([]); const [webhookConfigs, setWebhookConfigs] = useState<WebhookConfiguration[]>([]);
@@ -126,13 +128,13 @@ const NotificationSettings = () => {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>Notification Settings</CardTitle> <CardTitle>{t("titleNotification")}</CardTitle>
<CardDescription> <CardDescription>
Configure notification channels for your services {t("descriptionChannelsServices")}
</CardDescription> </CardDescription>
</div> </div>
<Button onClick={handleAddNew}> <Button onClick={handleAddNew}>
<Plus className="mr-2 h-4 w-4" /> Add Channel <Plus className="mr-2 h-4 w-4" /> {t("addChannel")}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -144,14 +146,15 @@ const NotificationSettings = () => {
className="w-full" className="w-full"
> >
<TabsList className="mb-4"> <TabsList className="mb-4">
<TabsTrigger value="all">All Channels</TabsTrigger> <TabsTrigger value="all">{t("all")}</TabsTrigger>
<TabsTrigger value="telegram">Telegram</TabsTrigger> <TabsTrigger value="telegram">{t("telegram")}</TabsTrigger>
<TabsTrigger value="discord">Discord</TabsTrigger> <TabsTrigger value="discord">{t("discord")}</TabsTrigger>
<TabsTrigger value="slack">Slack</TabsTrigger> <TabsTrigger value="slack">{t("slack")}</TabsTrigger>
<TabsTrigger value="signal">Signal</TabsTrigger> <TabsTrigger value="signal">{t("signal")}</TabsTrigger>
<TabsTrigger value="google_chat">Google Chat</TabsTrigger> <TabsTrigger value="google_chat">{t("googleChat")}</TabsTrigger>
<TabsTrigger value="email">Email</TabsTrigger> <TabsTrigger value="email">{t("email")}</TabsTrigger>
<TabsTrigger value="webhook">Webhook</TabsTrigger> <TabsTrigger value="webhook">{t("webhook")}</TabsTrigger>
<TabsTrigger value="matrix">{t("matrix")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value={currentTab} className="mt-0"> <TabsContent value={currentTab} className="mt-0">
@@ -1,4 +1,3 @@
import React from "react"; import React from "react";
import { import {
Dialog, Dialog,
@@ -17,8 +16,9 @@ import { Loader2, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import UserTextField from "./form-fields/UserTextField"; import UserTextField from "./form-fields/UserTextField";
import UserToggleField from "./form-fields/UserToggleField"; import UserToggleField from "./form-fields/UserToggleField";
import UserProfilePictureField from "./form-fields/UserProfilePictureField";
import UserRoleField from "./form-fields/UserRoleField"; import UserRoleField from "./form-fields/UserRoleField";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
interface EditUserDialogProps { interface EditUserDialogProps {
isOpen: boolean; isOpen: boolean;
@@ -26,6 +26,7 @@ interface EditUserDialogProps {
form: UseFormReturn<any>; form: UseFormReturn<any>;
user: User | null; user: User | null;
onSubmit: (data: any) => void; onSubmit: (data: any) => void;
onImpersonate: (data: any) => void;
isSubmitting?: boolean; isSubmitting?: boolean;
error?: string | null; error?: string | null;
} }
@@ -36,11 +37,19 @@ const EditUserDialog = ({
form, form,
user, user,
onSubmit, onSubmit,
onImpersonate,
isSubmitting = false, isSubmitting = false,
error = null error = null
}: EditUserDialogProps) => { }: EditUserDialogProps) => {
if (!user) return null; if (!user) return null;
const [impersonationDurationSeconds, setImpersonationDurationSeconds] = React.useState<number>(3600);
const handleImpersonateClick = () => {
// Impersonation should not depend on edit form validation.
onImpersonate({ user, durationSeconds: impersonationDurationSeconds });
};
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[700px] w-[95vw] max-h-[95vh] flex flex-col"> <DialogContent className="sm:max-w-[700px] w-[95vw] max-h-[95vh] flex flex-col">
@@ -62,7 +71,6 @@ const EditUserDialog = ({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<UserProfilePictureField control={form.control} />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<UserTextField <UserTextField
@@ -102,6 +110,23 @@ const EditUserDialog = ({
/> />
</form> </form>
</Form> </Form>
{/* Impersonation settings are not part of the form to avoid validation */}
<div className="mt-6 space-y-2">
<Label htmlFor="impersonation-duration">Impersonation token duration (seconds)</Label>
<Input
id="impersonation-duration"
type="number"
inputMode="numeric"
min={60}
step={60}
value={impersonationDurationSeconds}
onChange={(e) => setImpersonationDurationSeconds(Number(e.target.value || 0))}
/>
<p className="text-xs text-muted-foreground">
Default is 3600 (1 hour). Minimum 60 seconds.
</p>
</div>
</div> </div>
</ScrollArea> </ScrollArea>
@@ -127,6 +152,20 @@ const EditUserDialog = ({
"Update User" "Update User"
)} )}
</Button> </Button>
<Button
type="button"
onClick={handleImpersonateClick}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Impersonating...
</>
) : (
"Impersonate"
)}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -0,0 +1,65 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/utils/copyUtils";
interface ImpersonationTokenDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
token: string | null;
impersonatedUserLabel?: string;
}
const ImpersonationTokenDialog = ({
open,
onOpenChange,
token,
impersonatedUserLabel,
}: ImpersonationTokenDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[650px] w-[95vw]">
<DialogHeader>
<DialogTitle>Impersonation token</DialogTitle>
<DialogDescription>
{impersonatedUserLabel
? `Use this token to impersonate ${impersonatedUserLabel}. Keep it secret.`
: "Use this token to impersonate the selected user. Keep it secret."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<div className="rounded-md border bg-muted p-3">
<pre className="text-sm overflow-x-auto whitespace-pre-wrap break-all">
<code>{token ?? ""}</code>
</pre>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button
type="button"
onClick={() => {
if (token) copyToClipboard(token);
}}
disabled={!token}
>
Copy token
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ImpersonationTokenDialog;
@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import UserTable from "./UserTable"; import UserTable from "./UserTable";
import AddUserDialog from "./AddUserDialog"; import AddUserDialog from "./AddUserDialog";
import EditUserDialog from "./EditUserDialog"; import EditUserDialog from "./EditUserDialog";
import DeleteUserDialog from "./DeleteUserDialog"; import DeleteUserDialog from "./DeleteUserDialog";
import ImpersonationTokenDialog from "./ImpersonationTokenDialog";
import { useUserManagement } from "./hooks"; import { useUserManagement } from "./hooks";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { UserCog, Loader2, AlertCircle, Info, Users, ShieldAlert } from "lucide-react"; import { UserCog, Loader2, AlertCircle, Info, Users, ShieldAlert } from "lucide-react";
@@ -34,8 +34,12 @@ const UserManagement = () => {
handleDeletePrompt, handleDeletePrompt,
handleDeleteUser, handleDeleteUser,
onSubmit, onSubmit,
onImpersonate,
onAddUser, onAddUser,
fetchUsers fetchUsers,
impersonationToken,
isImpersonationTokenDialogOpen,
setIsImpersonationTokenDialogOpen,
} = useUserManagement(); } = useUserManagement();
// Get the current logged in user to check their role // Get the current logged in user to check their role
@@ -79,7 +83,14 @@ const UserManagement = () => {
<AddUserDialog isOpen={isAddUserDialogOpen && isSuperAdmin} setIsOpen={setIsAddUserDialogOpen} form={newUserForm} onSubmit={onAddUser} isSubmitting={isSubmitting} /> <AddUserDialog isOpen={isAddUserDialogOpen && isSuperAdmin} setIsOpen={setIsAddUserDialogOpen} form={newUserForm} onSubmit={onAddUser} isSubmitting={isSubmitting} />
<EditUserDialog isOpen={isDialogOpen} setIsOpen={setIsDialogOpen} form={form} user={currentUser} onSubmit={onSubmit} isSubmitting={isSubmitting} error={updateError} /> <EditUserDialog isOpen={isDialogOpen} setIsOpen={setIsDialogOpen} form={form} user={currentUser} onImpersonate={onImpersonate} onSubmit={onSubmit} isSubmitting={isSubmitting} error={updateError} />
<ImpersonationTokenDialog
open={isImpersonationTokenDialogOpen}
onOpenChange={setIsImpersonationTokenDialogOpen}
token={impersonationToken}
impersonatedUserLabel={currentUser?.full_name || currentUser?.username}
/>
<DeleteUserDialog isOpen={isDeleting} setIsOpen={setIsDeleting} user={userToDelete} onDelete={handleDeleteUser} isDeleting={isSubmitting} /> <DeleteUserDialog isOpen={isDeleting} setIsOpen={setIsDeleting} user={userToDelete} onDelete={handleDeleteUser} isDeleting={isSubmitting} />
</AccordionContent> </AccordionContent>
@@ -4,7 +4,6 @@ import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { UseFormReturn } from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import { UserProfilePictureField } from "./";
import { UserTextField } from "./"; import { UserTextField } from "./";
import { UserToggleField } from "./"; import { UserToggleField } from "./";
import { UserRoleField } from "./"; import { UserRoleField } from "./";
@@ -20,7 +19,6 @@ const AddUserForm = ({ form, onSubmit, isSubmitting }: AddUserFormProps) => {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<UserProfilePictureField control={form.control} />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<UserTextField <UserTextField
@@ -27,7 +27,7 @@ export const useUserForm = () => {
passwordConfirm: "", passwordConfirm: "",
isActive: true, isActive: true,
role: "user", role: "user",
avatar: avatarOptions[0].url, avatar: avatarOptions[Math.floor(Math.random() * avatarOptions.length)].url,
}, },
}); });
@@ -1,10 +1,10 @@
import { useUsersList } from "./useUsersList"; import { useUsersList } from "./useUsersList";
import { useUserForm } from "./useUserForm"; import { useUserForm } from "./useUserForm";
import { useUserDialogs } from "./useUserDialogs"; import { useUserDialogs } from "./useUserDialogs";
import { useUserOperations } from "./useUserOperations"; import { useUserOperations } from "./useUserOperations";
import { User } from "@/services/userService"; import { User } from "@/services/userService";
import { UserFormValues, NewUserFormValues } from "../userForms"; import { UserFormValues, NewUserFormValues } from "../userForms";
import { useState } from "react";
export const useUserManagement = () => { export const useUserManagement = () => {
const { users, loading, error, fetchUsers } = useUsersList(); const { users, loading, error, fetchUsers } = useUsersList();
@@ -32,6 +32,7 @@ export const useUserManagement = () => {
handleDeleteUser: baseHandleDeleteUser, handleDeleteUser: baseHandleDeleteUser,
onSubmit: baseOnSubmit, onSubmit: baseOnSubmit,
onAddUser: baseOnAddUser, onAddUser: baseOnAddUser,
onImpersonate: baseOnImpersonate,
} = useUserOperations( } = useUserOperations(
fetchUsers, fetchUsers,
setIsDialogOpen, setIsDialogOpen,
@@ -42,6 +43,9 @@ export const useUserManagement = () => {
newUserForm.reset newUserForm.reset
); );
const [impersonationToken, setImpersonationToken] = useState<string | null>(null);
const [isImpersonationTokenDialogOpen, setIsImpersonationTokenDialogOpen] = useState(false);
// Wrapper functions to provide the needed arguments // Wrapper functions to provide the needed arguments
const handleEditUser = (user: User) => { const handleEditUser = (user: User) => {
baseHandleEditUser(user, form); baseHandleEditUser(user, form);
@@ -56,6 +60,15 @@ export const useUserManagement = () => {
baseOnSubmit(data, currentUser); baseOnSubmit(data, currentUser);
}; };
const onImpersonate = async (data: UserFormValues) => {
const token = await baseOnImpersonate(data, currentUser);
if (token) {
setImpersonationToken(token);
setIsImpersonationTokenDialogOpen(true);
}
};
const onAddUser = (data: NewUserFormValues) => { const onAddUser = (data: NewUserFormValues) => {
baseOnAddUser(data); baseOnAddUser(data);
}; };
@@ -81,6 +94,10 @@ export const useUserManagement = () => {
handleDeletePrompt, handleDeletePrompt,
handleDeleteUser, handleDeleteUser,
onSubmit, onSubmit,
onImpersonate,
onAddUser, onAddUser,
impersonationToken,
isImpersonationTokenDialogOpen,
setIsImpersonationTokenDialogOpen,
}; };
}; };
@@ -1,4 +1,3 @@
import { useState } from "react"; import { useState } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { userService, User, UpdateUserData, CreateUserData } from "@/services/userService"; import { userService, User, UpdateUserData, CreateUserData } from "@/services/userService";
@@ -121,6 +120,38 @@ export const useUserOperations = (
} }
}; };
const onImpersonate = async (
data: UserFormValues & { durationSeconds?: number },
currentUser: User | null
): Promise<string | undefined> => {
const loggedInUser = authService.getCurrentUser();
if (!currentUser) return;
try {
const token = await authService.impersonateUser(currentUser.id, data?.durationSeconds ?? 3600);
toast({
title: "Impersonation token generated",
description: `Token generated for ${currentUser.full_name || currentUser.username}.`,
});
return token;
} catch (error) {
let description = "Error impersonating user.";
if (loggedInUser?.role !== "superadmin") {
description = "Only superadmin users can impersonate other users.";
}
toast({
title: "Error impersonating user",
description,
variant: "destructive",
});
throw error;
}
};
const onAddUser = async (data: NewUserFormValues) => { const onAddUser = async (data: NewUserFormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
@@ -177,6 +208,7 @@ export const useUserOperations = (
return { return {
handleDeleteUser, handleDeleteUser,
onSubmit, onSubmit,
onImpersonate,
onAddUser, onAddUser,
}; };
}; };
@@ -17,15 +17,6 @@ import { alertConfigService, AlertConfiguration } from "@/services/alertConfigSe
import { sslNotificationTemplateService, SslNotificationTemplate } from "@/services/sslNotificationTemplateService"; import { sslNotificationTemplateService, SslNotificationTemplate } from "@/services/sslNotificationTemplateService";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
const formSchema = z.object({
domain: z.string().min(1, "Domain is required"),
warning_threshold: z.coerce.number().int().min(1).max(365),
expiry_threshold: z.coerce.number().int().min(1).max(30),
notification_channels: z.array(z.string()).optional(),
alert_template: z.string().optional(),
check_interval: z.coerce.number().int().min(1).max(30).optional()
});
interface AddSSLCertificateFormProps { interface AddSSLCertificateFormProps {
onSubmit: (data: AddSSLCertificateDto) => Promise<void>; onSubmit: (data: AddSSLCertificateDto) => Promise<void>;
onCancel: () => void; onCancel: () => void;
@@ -38,6 +29,21 @@ export const AddSSLCertificateForm = ({
isPending = false isPending = false
}: AddSSLCertificateFormProps) => { }: AddSSLCertificateFormProps) => {
const { t } = useLanguage(); const { t } = useLanguage();
const formSchema = z.object({
domain: z.string()
.min(1, t("domain") + " is required")
.refine(
(value) => !/\s/.test(value),
t("spacesNotAllowed")
),
warning_threshold: z.coerce.number().int().min(1).max(365),
expiry_threshold: z.coerce.number().int().min(1).max(30),
notification_channels: z.array(z.string()).optional(),
alert_template: z.string().optional(),
check_interval: z.coerce.number().int().min(1).max(30).optional()
});
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]); const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [sslTemplates, setSslTemplates] = useState<SslNotificationTemplate[]>([]); const [sslTemplates, setSslTemplates] = useState<SslNotificationTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -154,7 +160,6 @@ export const AddSSLCertificateForm = ({
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="expiry_threshold" name="expiry_threshold"
@@ -189,7 +194,6 @@ export const AddSSLCertificateForm = ({
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="notification_channels" name="notification_channels"
@@ -289,7 +293,6 @@ export const AddSSLCertificateForm = ({
</FormItem> </FormItem>
)} )}
/> />
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>{t('cancel')}</Button> <Button type="button" variant="outline" onClick={onCancel}>{t('cancel')}</Button>
<Button type="submit" disabled={isPending || isLoading}> <Button type="submit" disabled={isPending || isLoading}>
@@ -2,24 +2,13 @@ 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 { import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
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 { import { fetchSSLCertificates, addSSLCertificate, checkAndUpdateCertificate, refreshAllCertificates, deleteSSLCertificate } from "@/services/ssl";
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";
@@ -32,23 +21,18 @@ 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] = const [selectedCertificate, setSelectedCertificate] = useState<SSLCertificate | null>(null);
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 { const { data: certificates = [], isLoading, error } = useQuery({
data: certificates = [], queryKey: ['ssl-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;
} }
}, },
@@ -60,34 +44,32 @@ 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( toast.error(error instanceof Error ? error.message : t('failedToAddCertificate'));
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 const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData);
.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
@@ -96,37 +78,33 @@ 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( toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate'));
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( toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate'));
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
}, },
@@ -134,40 +112,33 @@ 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( toast.success(t('allCertificatesRefreshed').replace('{count}', result.success.toString()));
t("allCertificatesRefreshed").replace(
"{count}",
result.success.toString()
)
);
} else { } else {
toast.info( toast.info(t('someCertificatesFailed')
t("someCertificatesFailed") .replace('{success}', result.success.toString())
.replace("{success}", result.success.toString()) .replace('{failed}', result.failed.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) => {
@@ -195,14 +166,12 @@ 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( toast.info(t('startingRefreshAll').replace('{count}', certificates.length.toString()));
t("startingRefreshAll").replace("{count}", certificates.length.toString())
);
refreshAllMutation.mutate(); refreshAllMutation.mutate();
}; };
@@ -213,29 +182,21 @@ 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 <Button onClick={() => queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] })}>
onClick={() => {t('check')}
queryClient.invalidateQueries({ queryKey: ["ssl-certificates"] })
}
>
{t("check")}
</Button> </Button>
</div> </div>
); );
} }
return ( return (
<main className="flex-1 flex flex-col overflow-auto bg-background pb-0 dark:bg-black p-2 "> <main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
<div className="flex flex-col flex-1 bg-white dark:bg-black p-6 rounded-xl"> <div className="flex flex-col flex-1">
<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"> <h2 className="text-2xl font-bold text-foreground">{t('sslDomainManagement')}</h2>
{t("sslDomainManagement")} <p className="text-sm text-muted-foreground mt-1">{t('monitorSSLCertificates')}</p>
</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
@@ -244,12 +205,8 @@ export const SSLDomainContent = () => {
disabled={isRefreshingAll || refreshingId !== null} disabled={isRefreshingAll || refreshingId !== null}
className="relative" className="relative"
> >
<RefreshCw <RefreshCw className={`w-4 h-4 mr-2 ${isRefreshingAll ? 'animate-spin' : ''}`} />
className={`w-4 h-4 mr-2 ${ {t('refreshAll')}
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">
... ...
@@ -260,7 +217,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>
@@ -275,7 +232,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}
@@ -286,16 +243,13 @@ export const SSLDomainContent = () => {
</Dialog> </Dialog>
{selectedCertificate && ( {selectedCertificate && (
<Dialog <Dialog open={isEditDialogOpen} onOpenChange={(open) => {
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}
@@ -1,14 +0,0 @@
// 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;
+47 -18
View File
@@ -1,48 +1,77 @@
import React, { createContext, useContext, useState, useEffect } from "react"; import React, { createContext, useContext, useState, useEffect } from 'react';
type SideBar = boolean;
interface SidebarContextType { interface SidebarContextType {
sidebarCollapsed: SideBar; sidebarCollapsed: boolean;
setSidebarCollapsed: React.Dispatch<React.SetStateAction<SideBar>>; setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebar: () => void; toggleSidebar: () => void;
isMobileOpen: boolean;
setIsMobileOpen: (open: boolean) => void;
toggleMobileMenu: () => void;
} }
const SidebarContext = createContext<SidebarContextType | undefined>(undefined); const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
interface SidebarProviderProps {
children: React.ReactNode; export const SidebarProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
} // Initialize sidebar collapsed state from localStorage with window check for safety
export const SidebarProvider: React.FC<SidebarProviderProps> = ({ const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
children, if (typeof window !== 'undefined') {
}) => {
// Initialize the state
const [sidebarCollapsed, setSidebarCollapsed] = useState<SideBar>(() => {
const saved = localStorage.getItem("sidebarCollapsed"); const saved = localStorage.getItem("sidebarCollapsed");
return saved === "true"; return saved ? JSON.parse(saved) : false;
}
return false;
}); });
// Fix: added isMobileOpen state for responsive mobile menu
const [isMobileOpen, setIsMobileOpen] = useState(false);
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebarCollapsed((prev) => { setSidebarCollapsed(prev => {
const newState = !prev; const newState = !prev;
localStorage.setItem("sidebarCollapsed", newState.toString()); if (typeof window !== 'undefined') {
localStorage.setItem("sidebarCollapsed", JSON.stringify(newState));
}
return newState; return newState;
}); });
}; };
// Fix: added toggleMobileMenu function to manage mobile drawer state
const toggleMobileMenu = () => {
setIsMobileOpen(prev => !prev);
};
// Fix: added resize listener to close mobile menu when switching to desktop view
useEffect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => {
if (window.innerWidth >= 1024) {
setIsMobileOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const value = { const value = {
sidebarCollapsed, sidebarCollapsed,
setSidebarCollapsed, setSidebarCollapsed,
toggleSidebar, toggleSidebar,
isMobileOpen,
setIsMobileOpen,
toggleMobileMenu
}; };
return ( return (
<SidebarContext.Provider value={value}>{children}</SidebarContext.Provider> <SidebarContext.Provider value={value}>
{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;
}; };
@@ -85,19 +85,19 @@ const ContainerMonitoring = () => {
/> />
<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 containers</h2> <h2 className="text-xl sm:text-2xl font-bold mb-4">{t('errorLoadingContainers')}</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 container data. Please check your connection and try again. {t('unableToFetchContainerData')}
</p> </p>
<div className="text-xs text-muted-foreground mb-4 font-mono"> <div className="text-xs text-muted-foreground mb-4 font-mono">
Error: {error?.message || 'Unknown error'} {t('errorUnknown')}: {error?.message || t('errorUnknown')}
</div> </div>
<div className="flex gap-2 justify-center"> <div className="flex gap-2 justify-center">
<Button onClick={handleRefresh} className="text-sm sm:text-base"> <Button onClick={handleRefresh} className="text-sm sm:text-base">
Retry {t('retry')}
</Button> </Button>
<Button onClick={handleBackToServers} variant="outline" className="text-sm sm:text-base"> <Button onClick={handleBackToServers} variant="outline" className="text-sm sm:text-base">
Back to Servers {t('backToServers')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -131,17 +131,17 @@ const ContainerMonitoring = () => {
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to Servers {t('backToServers')}
</Button> </Button>
</div> </div>
<h1 className="text-2xl font-bold text-foreground"> <h1 className="text-2xl font-bold text-foreground">
Container Monitoring {t('containerMonitoring')}
</h1> </h1>
<p className={`text-muted-foreground mt-1 sm:mt-2 transition-all duration-300 ${sidebarCollapsed ? 'text-base sm:text-lg' : 'text-sm sm:text-base'}`}> <p className={`text-muted-foreground mt-1 sm:mt-2 transition-all duration-300 ${sidebarCollapsed ? 'text-base sm:text-lg' : 'text-sm sm:text-base'}`}>
Monitor and manage your Docker containers in real-time {t('monitorAndManageContainers')}
{serverId && ( {serverId && (
<span className="block text-xs text-muted-foreground/70 mt-1"> <span className="block text-xs text-muted-foreground/70 mt-1">
Server ID: {serverId} {t('serverIdLabel')}: {serverId}
</span> </span>
)} )}
</p> </p>
+5 -39
View File
@@ -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 { Header } from "@/components/dashboard/Header"; import { Header } from "@/components/dashboard/Header";
@@ -11,72 +10,39 @@ import { LoadingState } from "@/components/services/LoadingState";
import { useSidebar } from "@/contexts/SidebarContext"; import { useSidebar } from "@/contexts/SidebarContext";
const Dashboard = () => { const Dashboard = () => {
// Use shared sidebar state
const { sidebarCollapsed, toggleSidebar } = useSidebar();
// Get current user
const currentUser = authService.getCurrentUser(); const currentUser = authService.getCurrentUser();
const navigate = useNavigate(); const navigate = useNavigate();
// For debugging user data
useEffect(() => {
// console.log("Current user data:", currentUser);
}, [currentUser]);
// Handle logout
const handleLogout = () => { const handleLogout = () => {
authService.logout(); authService.logout();
navigate("/login"); navigate("/login");
}; };
// Fetch all services with 1-minute polling for real-time updates
const { data: services = [], isLoading, error } = useQuery({ const { data: services = [], isLoading, error } = useQuery({
queryKey: ['services'], queryKey: ['services'],
queryFn: serviceService.getServices, queryFn: serviceService.getServices,
refetchInterval: 60000, // 1 minute as requested refetchInterval: 60000,
staleTime: 30000, // Data is fresh for 30 seconds
gcTime: 120000, // Keep in cache for 2 minutes
refetchOnWindowFocus: true, // Refetch when window gains focus
refetchOnMount: true, // Refetch on mount
refetchOnReconnect: true, // Refetch on reconnect
retry: 2,
retryDelay: 3000,
}); });
// Start monitoring all active services when the dashboard loads - only once
useEffect(() => { useEffect(() => {
let hasStarted = false;
const startActiveServices = async () => { const startActiveServices = async () => {
if (hasStarted) return;
hasStarted = true;
await serviceService.startAllActiveServices(); await serviceService.startAllActiveServices();
// console.log("Active services monitoring started");
}; };
// Only start once and add a delay to prevent immediate execution
const timeoutId = setTimeout(startActiveServices, 2000); const timeoutId = setTimeout(startActiveServices, 2000);
return () => clearTimeout(timeoutId);
}, []);
return () => {
clearTimeout(timeoutId);
};
}, []); // Remove services dependency to prevent re-runs
// Show the loading state while fetching data
if (isLoading) { if (isLoading) {
return <LoadingState />; return <LoadingState />;
} }
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 />
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1 min-w-0">
<Header <Header
currentUser={currentUser} currentUser={currentUser}
onLogout={handleLogout} onLogout={handleLogout}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/> />
<DashboardContent <DashboardContent
services={services} services={services}
+18 -86
View File
@@ -9,37 +9,29 @@ import { ServerTable } from "@/components/servers/ServerTable";
import { AddServerAgentDialog } from "@/components/servers/AddServerAgentDialog"; import { AddServerAgentDialog } from "@/components/servers/AddServerAgentDialog";
import { serverService } from "@/services/serverService"; import { serverService } from "@/services/serverService";
import { Server, ServerStats } from "@/types/server.types"; import { Server, ServerStats } from "@/types/server.types";
import { useSidebar } from "@/contexts/SidebarContext";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
const InstanceMonitoring = () => { const InstanceMonitoring = () => {
const { theme } = useTheme();
const { t } = useLanguage(); const { t } = useLanguage();
const { sidebarCollapsed, toggleSidebar } = useSidebar();
const navigate = useNavigate(); const navigate = useNavigate();
const [stats, setStats] = useState<ServerStats>({ const [stats, setStats] = useState<ServerStats>({
total: 0, total: 0,
online: 0, online: 0,
offline: 0, offline: 0,
warning: 0, warning: 0
}); });
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser()); const [currentUser] = useState(authService.getCurrentUser());
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
const { const { data: servers = [], isLoading, error, refetch } = useQuery<Server[]>({
data: servers = [], queryKey: ['servers'],
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["servers"],
queryFn: serverService.getServers, queryFn: serverService.getServers,
refetchInterval: 30000, // Refetch every 30 seconds refetchInterval: 30000
}); });
useEffect(() => { useEffect(() => {
@@ -48,98 +40,38 @@ const InstanceMonitoring = () => {
} }
}, [servers]); }, [servers]);
const handleRefresh = () => {
refetch();
};
const handleLogout = () => { const handleLogout = () => {
authService.logout(); authService.logout();
navigate("/login"); navigate('/login');
}; };
const handleAgentAdded = () => {
refetch();
};
if (error) {
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 />
<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}
toggleSidebar={toggleSidebar}
/> />
<main className="flex-1 flex flex-col items-center justify-center p-4 sm:p-6 "> <main className="flex-1 overflow-auto">
<div className="text-center max-w-md w-full"> <div className="p-4 lg:p-8 space-y-6">
<h2 className="text-xl sm:text-2xl font-bold mb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
Error loading servers
</h2>
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
Unable to fetch server data. Please check your connection and
try again.
</p>
<button
onClick={handleRefresh}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 text-sm sm:text-base"
>
Retry
</button>
</div>
</main>
</div>
</div>
);
}
return (
<div className="flex h-screen overflow-hidden bg-background text-foreground ">
<Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-col flex-1 min-w-0 ">
<Header
currentUser={currentUser}
onLogout={handleLogout}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/>
<main className="flex-1 overflow-auto dark:bg-black p-2 shadow">
<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 */}
<div>
<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 font-bold text-foreground">
Instance Monitoring {t('instanceMonitoring')}
</h1> </h1>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"> <p className="text-muted-foreground mt-1 text-sm">
Monitor and manage your server instances in real-time {t('describeMonitorInstance')}
</p> </p>
</div> </div>
<Button <Button onClick={() => setAddDialogOpen(true)} className="flex-shrink-0">
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 {t('addServerAgent')}
</Button> </Button>
</div> </div>
</div>
{/* Stats Cards Section */}
<div className="p-2 ">
<ServerStatsCards stats={stats} /> <ServerStatsCards stats={stats} />
</div> <ServerTable servers={servers} isLoading={isLoading} onRefresh={refetch} />
{/* Server Table Section */}
<div className="p-3">
<ServerTable
servers={servers}
isLoading={isLoading}
onRefresh={handleRefresh}
/>
</div>
</div> </div>
</main> </main>
</div> </div>
@@ -147,7 +79,7 @@ const InstanceMonitoring = () => {
<AddServerAgentDialog <AddServerAgentDialog
open={addDialogOpen} open={addDialogOpen}
onOpenChange={setAddDialogOpen} onOpenChange={setAddDialogOpen}
onAgentAdded={handleAgentAdded} onAgentAdded={refetch}
/> />
</div> </div>
); );
+11 -8
View File
@@ -120,15 +120,15 @@ const ServerDetail = () => {
/> />
<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 server</h2> <h2 className="text-xl sm:text-2xl font-bold mb-4">{t('errorLoadingServer')}</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. {t('unableToFetchServerData')}
</p> </p>
<div className="text-xs text-muted-foreground mb-4 font-mono"> <div className="text-xs text-muted-foreground mb-4 font-mono">
Error: {serverError?.message || 'Unknown error'} Error: {serverError?.message || 'Unknown error'}
</div> </div>
<Button onClick={handleBackToServers} variant="outline" className="text-sm sm:text-base"> <Button onClick={handleBackToServers} variant="outline" className="text-sm sm:text-base">
Back to Servers {t('backToServers')}
</Button> </Button>
</div> </div>
</main> </main>
@@ -151,7 +151,7 @@ const ServerDetail = () => {
<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"> <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading server details...</p> <p className="text-muted-foreground">{t('loadingServerDetails')}</p>
</div> </div>
</main> </main>
</div> </div>
@@ -183,7 +183,7 @@ const ServerDetail = () => {
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to Servers {t('backToServers')}
</Button> </Button>
</div> </div>
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
@@ -199,14 +199,17 @@ const ServerDetail = () => {
)} )}
</div> </div>
<h1 className="text-2xl font-bold text-foreground"> <h1 className="text-2xl font-bold text-foreground">
{server?.name || 'Server Detail'} {server?.name || t('serverDetail')}
</h1> </h1>
</div> </div>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base"> <p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
Monitor server performance metrics and system health {t('monitorServerMetrics')}
{server && ( {server && (
<span className="block text-xs text-muted-foreground/70 mt-1"> <span className="block text-xs text-muted-foreground/70 mt-1">
{server.hostname} {server.ip_address} {server.os_type} {t('serverHostnameIpOs')
.replace('{hostname}', server.hostname)
.replace('{ip_address}', server.ip_address)
.replace('{os_type}', server.os_type)}
</span> </span>
)} )}
</p> </p>
+7 -17
View File
@@ -1,3 +1,4 @@
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";
@@ -6,11 +7,7 @@ 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 { import { fetchSSLCertificates, shouldRunDailyCheck, checkAllCertificatesAndNotify } from "@/services/sslCertificateService";
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";
@@ -26,13 +23,8 @@ 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 { const { data: certificates = [], isLoading, error, refetch } = useQuery({
data: certificates = [], queryKey: ['ssl-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 {
@@ -102,17 +94,15 @@ const SslDomain = () => {
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"> <h2 className="text-xl font-bold mb-2">{t('failedToLoadCertificates')}</h2>
{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>
@@ -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" | "notifiarr" | "gotify" | "webhook"; notification_type: "telegram" | "discord" | "slack" | "signal" | "google_chat" | "email" | "ntfy" | "pushover" | "notifiarr" | "gotify" | "webhook" | "matrix";
telegram_chat_id?: string; telegram_chat_id?: string;
discord_webhook_url?: string; discord_webhook_url?: string;
signal_number?: string; signal_number?: string;
@@ -34,6 +34,9 @@ export interface AlertConfiguration {
server_url?: string; server_url?: string;
webhook_url?: string; webhook_url?: string;
webhook_payload_template?: string; webhook_payload_template?: string;
matrix_homeserver?: string;
matrix_room_id?: string;
matrix_access_token?: string;
} }
export const alertConfigService = { export const alertConfigService = {
@@ -106,6 +109,11 @@ export const alertConfigService = {
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 || "";
} else if (config.notification_type === "matrix") {
cleanConfig.matrix_homeserver = config.matrix_homeserver || "";
cleanConfig.matrix_room_id = config.matrix_room_id || "";
cleanConfig.matrix_access_token = config.matrix_access_token || "";
} }
const result = await pb.collection('alert_configurations').create(cleanConfig); const result = await pb.collection('alert_configurations').create(cleanConfig);
+5 -1
View File
@@ -1,4 +1,3 @@
import { pb } from '@/lib/pocketbase'; import { pb } from '@/lib/pocketbase';
export interface LoginCredentials { export interface LoginCredentials {
@@ -98,5 +97,10 @@ export const authService = {
} catch (error) { } catch (error) {
// console.error('Failed to refresh user data:', error); // console.error('Failed to refresh user data:', error);
} }
},
async impersonateUser(userId: string, durationSeconds: number = 3600): Promise<string | undefined> {
const impersonateClient = await pb.collection("users").impersonate(userId, durationSeconds);
return impersonateClient.authStore?.token;
} }
}; };
@@ -6,6 +6,8 @@ export interface ServerThreshold {
name: string; name: string;
cpu_threshold: number; cpu_threshold: number;
ram_threshold: number; ram_threshold: number;
// Some API responses may use a different field name; include it as optional
ram_threshold_message?: number;
disk_threshold: number; disk_threshold: number;
network_threshold: number; network_threshold: number;
created: string; created: string;
+1
View File
@@ -17,6 +17,7 @@ export const loginTranslations: LoginTranslations = {
loginSuccessMessage: "Sie wurden erfolgreich angemeldet.", loginSuccessMessage: "Sie wurden erfolgreich angemeldet.",
loginFailed: "Anmeldung fehlgeschlagen", loginFailed: "Anmeldung fehlgeschlagen",
authenticationFailed: "Authentifizierung fehlgeschlagen", authenticationFailed: "Authentifizierung fehlgeschlagen",
invalidCredentials: "Ungültige E-Mail oder Passwort",
bySigningIn: "Durch die Anmeldung stimmen Sie unseren", bySigningIn: "Durch die Anmeldung stimmen Sie unseren",
termsAndConditions: "Allgemeinen Geschäftsbedingungen", termsAndConditions: "Allgemeinen Geschäftsbedingungen",
and: "und", and: "und",
+71
View File
@@ -0,0 +1,71 @@
import { DockerTranslations } from '../types/docker';
export const dockerTranslations: DockerTranslations = {
dockerContainers: "Docker Containers",
container: "Container",
status: "Status",
cpuUsage: "CPU Usage",
memory: "Memory",
disk: "Disk",
uptime: "Uptime",
lastChecked: "Last Checked",
actions: "Actions",
searchContainersPlaceholder: "Search containers...",
refresh: "Refresh",
openMenu: "Open menu",
viewMetrics: "View Metrics",
viewDetails: "View Details",
running: "Running",
stopped: "Stopped",
warning: "Warning",
unknown: "Unknown",
totalContainers: "Total Containers",
containersLabel: "Containers",
noContainersFound: "No containers found",
noContainersRunning: "No containers running",
tryAdjustSearch: "Try adjusting your search terms.",
startSomeContainers: "Start some containers to see them here.",
containerMetricsTitle: "Container Metrics: {name}",
dockerId: "Docker ID",
loadingMetrics: "Loading metrics...",
errorLoadingMetrics: "Error loading metrics",
noMetricsAvailable: "No metrics data available for this container",
minutes60: "60 minutes",
day1: "1 day",
days7: "7 days",
month1: "1 month",
months3: "3 months",
used: "Used:",
free: "Free:",
total: "Total:",
usage: "Usage:",
cpu: "CPU",
memoryTitle: "Memory",
diskTitle: "Disk",
network: "Network",
cpuUsagePct: "CPU Usage (%)",
cpuUsageVsAvailable: "CPU Usage vs Available",
cpuAvailablePct: "CPU Available (%)",
ramUsagePct: "RAM Usage (%)",
memoryUsageBytes: "Memory Usage (Bytes)",
usedMemory: "Used Memory",
totalMemory: "Total Memory",
diskUsagePct: "Disk Usage (%)",
diskUsageBytes: "Disk Usage (Bytes)",
usedDisk: "Used Disk",
totalDisk: "Total Disk",
networkTraffic: "Network Traffic",
networkSpeedKbs: "Network Speed (KB/s)",
rxBytes: "RX Bytes",
txBytes: "TX Bytes",
rxSpeedKbs: "RX Speed (KB/s)",
txSpeedKbs: "TX Speed (KB/s)",
};
+11 -1
View File
@@ -9,6 +9,11 @@ import { maintenanceTranslations } from './maintenance';
import { incidentTranslations } from './incident'; import { incidentTranslations } from './incident';
import { sslTranslations } from './ssl'; import { sslTranslations } from './ssl';
import { settingsTranslations } from './settings'; import { settingsTranslations } from './settings';
import { instanceTranslations } from './instance';
import { operationTranslations } from './operation';
import { regionTranslations } from './region';
import { dockerTranslations } from './docker';
import { publicTranslations } from './public';
const enTranslations: Translations = { const enTranslations: Translations = {
common: commonTranslations, common: commonTranslations,
@@ -19,7 +24,12 @@ const enTranslations: Translations = {
maintenance: maintenanceTranslations, maintenance: maintenanceTranslations,
incident: incidentTranslations, incident: incidentTranslations,
ssl: sslTranslations, ssl: sslTranslations,
settings: settingsTranslations settings: settingsTranslations,
instance: instanceTranslations,
operation: operationTranslations,
region: regionTranslations,
docker: dockerTranslations,
public: publicTranslations
}; };
export default enTranslations; export default enTranslations;
+176
View File
@@ -0,0 +1,176 @@
import { InstanceTranslations } from '../types/instance';
export const instanceTranslations: InstanceTranslations = {
// InstanceMonitoring.tsx
instanceMonitoring: "Instance Monitoring",
describeMonitorInstance: "Monitor and manage your server instances in real-time",
addServerAgent: "Add Server Agent",
errorLoadingServers: "Error loading servers",
unableToFetchServerData: "Unable to fetch server data. Please check your connection and try again.",
retry: "Retry",
// ServerTable.tsx
servers: "Servers",
loadingServers: "Loading servers...",
searchServersPlaceholder: "Search servers...",
noServersFound: "No servers found",
deleteSelected: "Delete Selected",
deleteSelectedConfirmTitle: "Delete selected servers?",
deleteSelectedConfirmDesc: "This action cannot be undone. This will permanently delete {count} server(s) and all of their monitoring data.",
selectedCount: "{count} selected",
serversDeleted: "Servers deleted",
serversDeletedDesc: "{count} server(s) have been deleted.",
partialSuccess: "Partial success",
name: "Name",
status: "Status",
OS: "OS",
IPAddress: "IP Address",
CPU: "CPU",
memory: "Memory",
disk: "Disk",
uptime: "Uptime",
lastChecked: "Last Checked",
actions: "Actions",
viewServerDetail: "View Server Detail",
containerMonitoring: "Container Monitoring",
resumeMonitoring: "Resume Monitoring",
pauseMonitoring: "Pause Monitoring",
editServer: "Edit Server",
deleteServer: "Delete Server",
deleteServerConfirmTitle: "Are you sure you want to delete this server?",
deleteServerConfirmDesc: "This action cannot be undone. This will permanently delete {name} and all of its monitoring data.",
cancel: "Cancel",
deleting: "Deleting...",
delete: "Delete",
serverDeleted: "Server deleted",
serverDeletedDesc: "{name} has been deleted successfully.",
error: "Error",
deleteServerError: "Failed to delete server. Please try again.",
serverPaused: "Server paused",
serverResumed: "Server resumed",
monitoringPaused: "Monitoring paused for {name}",
monitoringResumed: "Monitoring resumed for {name}",
pauseServerError: "Failed to pause server monitoring. Please try again.",
resumeServerError: "Failed to resume server monitoring. Please try again.",
// server cards
totalServers: "TOTAL SERVERS",
onlineServers: "ONLINE SERVERS",
offlineServers: "OFFLINE SERVERS",
warningServers: "WARNING SERVERS",
// AddServerAgentDialog.tsx
addServerMonitoringAgent: "Add Server Monitoring Agent",
configureAgentDesc: "Configure a new server monitoring agent to track system metrics and performance.",
configureAgent: "Configure Agent",
oneClickInstall: "One-Click Install",
dockerOneClick: "Docker One-Click",
manualInstallation: "Manual Installation",
validationError: "Validation Error",
fillRequiredFields: "Please fill in all required fields.",
serverAgentCreated: "Server Agent Created",
serverAgentCreatedDesc: "{name} monitoring agent has been configured successfully.",
failedToCreateAgent: "Failed to create server monitoring agent.",
// ServerAgentConfigForm.tsx
serverName: "Server Name",
serverNamePlaceholder: "e.g., web-server-01",
serverNameDesc: "What is the name or label used as the identifier",
serverAgentId: "Server Agent ID",
serverAgentIdDesc: "Auto-generated unique identifier",
operatingSystem: "Operating System",
checkInterval: "Check Interval",
selectInterval: "Select interval",
interval30s: "30 seconds",
interval1m: "1 minute",
interval2m: "2 minutes",
interval5m: "5 minutes",
checkIntervalDesc: "How often to check the server and metric status",
retryAttempts: "Retry Attempts",
selectRetryAttempts: "Select retry attempts",
attempt1: "1 attempt",
attempt2: "2 attempts",
attempt3: "3 attempts",
attempt5: "5 attempts",
retryAttemptsDesc: "Number of retry attempts before marking as down",
serverToken: "Server Token",
serverTokenDesc: "Auto-generated authentication token",
systemUrl: "System URL",
systemUrlDesc: "Current system API URL",
creatingAgent: "Creating Agent...",
createServerAgent: "Create Server Agent"
,
// EditServerDialog.tsx
editServerConfiguration: "Edit Server Configuration",
serverNameLabel: "Server Name *",
enterServerNamePlaceholder: "Enter server name",
checkIntervalLabel: "Check Interval",
interval10m: "10 minutes",
maxRetriesLabel: "Max Retries",
selectMaxRetries: "Select max retries",
retry1: "1 retry",
retry2: "2 retries",
retry3: "3 retries",
retry5: "5 retries",
retry10: "10 retries",
dockerMonitoring: "Docker Monitoring",
enabled: "Enabled",
disabled: "Disabled",
enableNotifications: "Enable Notifications",
notificationSettings: "Notification Settings",
notificationChannels: "Notification Channels",
loadingChannels: "Loading channels...",
noChannelsAvailable: "No notification channels available",
selectedChannels: "Selected Channels:",
serverSetThreshold: "Server Set Threshold",
loadingThresholds: "Loading thresholds...",
selectServerThreshold: "Select server threshold",
noThreshold: "No threshold (use default)",
thresholdDetails: "Threshold Details",
cpuThresholdPct: "CPU Threshold (%)",
ramThresholdPct: "RAM Threshold (%)",
diskThresholdPct: "Disk Threshold (%)",
networkThresholdPct: "Network Threshold (%)",
serverTemplate: "Server Template",
loadingTemplates: "Loading templates...",
selectServerTemplate: "Select server template",
noTemplate: "No template (use default)",
templateDetails: "Template Details",
ramMessage: "RAM Message",
cpuMessage: "CPU Message",
diskMessage: "Disk Message",
networkMessage: "Network Message",
upMessage: "Up Message",
downMessage: "Down Message",
noMessageDefined: "No {name} message defined",
serverUpdated: "Server updated",
serverUpdatedDesc: "{name} has been updated successfully.",
updating: "Updating...",
updateServer: "Update Server",
failedToLoadChannels: "Failed to load notification channels",
failedToLoadTemplates: "Failed to load templates",
failedToLoadThresholds: "Failed to load server thresholds",
warning: "Warning",
serverUpdatedButThresholdFailed: "Server updated but failed to update threshold values.",
failedToUpdateServer: "Failed to update server. Please try again.",
// ServerHistoryCharts.tsx
historicalPerformance: "Historical Performance",
loading: "Loading...",
errorLoadingChartData: "Error loading chart data",
noHistoricalData: "No historical data available for {{timeRange}}",
rawMetricsCount: "Raw metrics count: {{count}}",
serverIdTimeRange: "Server ID: {{serverId}} • Time Range: {{timeRange}}",
dataExistsOutsideRange: "Data exists but may be outside selected time range",
noMetricsDataFound: "No metrics data found",
dataPointsTimeRange: "{{count}} data points • {{timeRange}}",
// updating: "Updating...",
};
+1
View File
@@ -16,6 +16,7 @@ export const loginTranslations: LoginTranslations = {
loginSuccessMessage: "You have been logged in successfully.", loginSuccessMessage: "You have been logged in successfully.",
loginFailed: "Login failed", loginFailed: "Login failed",
authenticationFailed: "Authentication failed", authenticationFailed: "Authentication failed",
invalidCredentials: "Invalid email or password",
bySigningIn: "By signing in, you agree to our", bySigningIn: "By signing in, you agree to our",
termsAndConditions: "Terms & Conditions", termsAndConditions: "Terms & Conditions",
and: "and", and: "and",
@@ -0,0 +1,87 @@
import { OperationTranslations } from '../types/operation';
export const operationTranslations: OperationTranslations = {
// OperationalPageContent.tsx
failedToLoadOperationalPages: "Failed to load operational pages",
loadingoperationalPages: "There was an error loading your operational pages. Please try again.",
tryagain: "Try Again",
operationalPages: "Operational Pages",
describeOperation: "Manage your public status pages and monitor service health",
refresh: "Refresh",
noOperationalPagesFound: "No operational pages found",
createYourFirstOperationalPage: "Create your first operational page to start monitoring your services and communicate status to your users.",
totalPages: "Total Pages",
publicPages: "Public Pages",
operational: "Operational",
deleteOperationalPage: "Delete Operational Page",
deleteOperationalPageConfirm: 'Are you sure you want to delete "{title}"? This action cannot be undone and will permanently remove the operational page and all its components.',
cancel: "Cancel",
delete: "Delete",
deleting: "Deleting...",
// EditOperationalPageDialog.tsx
editOperationalPage: "Edit Operational Page",
updateYourOperationalPage: "Update your operational status page settings and manage components.",
title: "Title",
myServiceStatusPlaceholder: "My Service Status",
slug: "Slug",
myServiceStatusSlugPlaceholder: "my-service-status",
description: "Description",
operationalPageDescriptionPlaceholder: "A brief description of your operational page",
theme: "Theme",
selectTheme: "Select theme",
themeDefault: "Default",
themeDark: "Dark",
themeLight: "Light",
status: "Status",
selectStatus: "Select status",
statusOperational: "Operational",
statusDegraded: "Degraded Performance",
statusMaintenance: "Under Maintenance",
statusMajorOutage: "Major Outage",
publicPage: "Public Page",
makePagePublic: "Make this page publicly accessible",
customDomainOptional: "Custom Domain (Optional)",
customDomainPlaceholder: "status.yourdomain.com",
customDomainDescription: "Custom domain for your status page",
cancelUpdate: "Cancel",
updating: "Updating...",
updatePage: "Update Page",
// ComponentsSelector.tsx
statusPageComponents: "Status Page Components",
addMonitoringComponentsDesc: "Add monitoring components like uptime services, SSL certificates, and incident tracking",
selectedComponents: "Selected Components",
service: "Service",
server: "Server",
addComponent: "Add Component",
componentName: "Component Name",
componentNamePlaceholder: "e.g., Main Website",
displayOrder: "Display Order",
descriptionOptional: "Description (Optional)",
descriptionPlaceholder: "Brief description of this component",
uptimeServiceOptional: "Uptime Service (Optional)",
selectUptimeService: "Select an uptime service",
serverIdOptional: "Server ID (Optional)",
serverIdPlaceholder: "server_456",
cancelComponent: "Cancel",
// OperationalPageCard.tsx
public: "Public",
yes: "Yes",
no: "No",
updated: "Updated",
view: "View",
edit: "Edit",
// CreateOperationalPageDialog.tsx
createOperationalPage: "Create Operational Page",
createOperationalPageDesc: "Create a new operational status page to monitor your services and components.",
createPage: "Create Page",
creating: "Creating...",
initialStatus: "Initial Status",
};
+23
View File
@@ -0,0 +1,23 @@
import { PublicTranslations } from '../types/public';
export const publicTranslations: PublicTranslations = {
systemStatus: "System Status",
allOperational: "All systems are operational",
degradedPerformance: "Some systems are experiencing degraded performance",
underMaintenance: "Systems are currently under maintenance",
majorOutage: "We are experiencing a major service outage",
statusUnknown: "Status unknown",
autoUpdatedByHealth: "Status automatically updated based on component health",
lastUpdatedAt: "Last updated: {time} UTC",
liveStatusMonitoring: "Live status monitoring",
loadingStatusPage: "Loading Status Page",
fetchingRealtimeStatus: "Fetching real-time system status...",
slugLabel: "Slug",
statusPageNotFound: "Status Page Not Found",
notFoundDescription: "The requested status page could not be found or is not publicly accessible.",
goBack: "Go Back",
retry: "Retry",
};
+95
View File
@@ -0,0 +1,95 @@
import { RegionTranslations } from '../types/region';
export const regionTranslations: RegionTranslations = {
//RegionalMonitoringContent.tsx
regionalmonitoring: "Regional Monitoring",
descriptRegionPage: "Monitor your infrastructure from multiple regions for better reliability.",
addRegionalAgent: "Add Regional Agent",
totalAgents: "Total Agents",
regionalMonitoringAgents: "Regional monitoring agents",
onlineAgents: "Online Agents",
currentlyConnected: "Currently connected",
offlineAgents: "Offline Agents",
disconnectedAgents: "Disconnected agents",
regionalAgents: "Regional Agents",
noRegionalAgents: "No Regional Agents",
getStartedAddAgent: "Get started by adding your first regional monitoring agent to extend your monitoring coverage.",
addFirstAgent: "Add First Agent",
agentRemoved: "Agent Removed",
agentRemovedDesc: "The regional monitoring agent has been removed.",
regionalAgentAdded: "Regional Agent Added",
regionalAgentAddedDesc: "The regional monitoring agent has been successfully configured.",
error: "Error",
failedToRemoveAgent: "Failed to remove the regional monitoring agent.",
//AddRegionalAgentDialog.tsx
addRegionalMonitoringAgent: "Add Regional Monitoring Agent",
deployRegionalMonitoringAgent: "Deploy a regional monitoring agent with automatic one-click installation.",
regionName: "Region Name",
regionNamePlaceholder: "e.g., us-east-1, europe-west-1, asia-pacific-1",
agentServerIpAddress: "Agent Server IP Address",
agentIpPlaceholder: "e.g., 192.168.1.100 or your-server.example.com",
cancel: "Cancel",
generateInstallation: "Generate Installation",
generating: "Generating...",
agentConfigurationReady: "Agent Configuration Ready!",
oneClickInstallScriptGenerated: "One-click installation script generated with automatic configuration.",
oneClickInstallTab: "One-Click Install",
agentDetailsTab: "Agent Details",
manualInstallTab: "Manual Install",
oneClickAutomaticInstallation: "One-Click Automatic Installation",
completeInstallationDescription: "Complete installation, configuration, and service startup in one command",
whatThisScriptDoes: "What this script does automatically:",
scriptActionDownload: "Downloads the latest .deb package",
scriptActionInstall: "Installs the regional monitoring agent",
scriptActionConfigure: "Creates configuration file with your settings",
scriptActionStart: "Starts and enables the service",
scriptActionHealthChecks: "Runs health checks",
runCommandOnServer: "Run this command on your target server:",
downloadCompleteScript: "Download Complete Script",
copyFullScript: "Copy Full Script",
localDevelopmentNotice: "<strong>Local Development Notice:</strong> If clipboard copying fails, this is normal in local development. You can use the 'Download Complete Script' button instead or manually copy the text.",
generatedAgentConfiguration: "Generated Agent Configuration",
autoConfiguredDuringInstallation: "These values will be automatically configured during installation",
agentId: "Agent ID",
regionNameDetail: "Region Name",
serverIp: "Server IP",
apiEndpoint: "API Endpoint",
authenticationToken: "Server Token",
createAgent: "Create Agent",
configurationFileLocation: "<strong>Configuration file location:</strong> /etc/regional-check-agent/regional-check-agent.env",
manualInstallationSteps: "Manual Installation Steps",
stepByStepManualInstallation: "Step-by-step manual installation process",
step1DownloadPackage: "Step 1: Download Package",
downloadAmd64Notice: "✅ If youre using the amd64 architecture, please download this package.",
downloadArm64Notice: "✅ If youre using the arm64 architecture, please download this package.",
step2InstallPackage: "Step 2: Install Package",
step3ConfigureAgent: "Step 3: Configure Agent",
copySampleConfiguration: "Copy sample configuration from https://github.com/operacle/Distributed-Regional-Monitoring/blob/main/packaging/regional-check-agent.conf",
addConfigurationValues: "Add the configuration values shown in the Agent Details tab",
step4StartService: "Step 4: Start Service",
step5VerifyInstallation: "Step 5: Verify Installation",
addAnotherAgent: "Add Another Agent",
completeSetup: "Complete Setup",
copied: "Copied!",
copiedDescription: "{description} copied to clipboard.",
copyFailed: "Copy failed",
copyFailedDescription: "Failed to copy to clipboard.",
copyFailedLocalhost: "This is common in local development. Please manually copy the text.",
copyFailedChrome: "Try using HTTPS or enable clipboard permissions.",
downloaded: "Downloaded!",
downloadedDescription: "Installation script downloaded successfully.",
failedToCreateAgent: "Failed to create regional agent configuration.",
//RegionalAgentCard.tsx
defaultBadge: "Default",
copyAgentId: "Copy Agent ID",
removeAgent: "Remove Agent",
online: "Online",
offline: "Offline",
lastUpdated: "Last Updated:",
activeMonitoring: "Active monitoring",
connectionLost: "Connection lost",
};
@@ -88,6 +88,7 @@ export const servicesTranslations: ServicesTranslations = {
serviceNameRequired: "Service name is required", serviceNameRequired: "Service name is required",
urlDomainHostRequired: "URL/Domain/Host is required", urlDomainHostRequired: "URL/Domain/Host is required",
enterValidUrlHostnameDomain: "Please enter a valid URL, hostname, or domain", enterValidUrlHostnameDomain: "Please enter a valid URL, hostname, or domain",
spacesNotAllowed: "Spaces are not allowed",
// Dashboard // Dashboard
upServices: "UP SERVICES", upServices: "UP SERVICES",
@@ -136,4 +137,65 @@ export const servicesTranslations: ServicesTranslations = {
processing: "Processing", processing: "Processing",
back: "Back", back: "Back",
last20Checks: "Last 20 checks", last20Checks: "Last 20 checks",
//OneClickInstallTab.tsx
oneClickInstallTitle: "One-Click Install (Recommended)",
oneClickInstallDesc: "Copy and paste this single command to install the monitoring agent instantly",
quickInstallCommand: "Quick Install Command",
copy: "Copy",
runCommandOnServer: "Simply run this command on your server:",
sshIntoServer: "SSH into your target server",
pasteAndRun: "Paste and run the command above",
agentInstalled: "The agent will be installed and started automatically",
done: "Done",
// DockerOneClickTab.tsx
dockerOneClickTitle: "Docker One-Click Install",
dockerOneClickDesc: "Automated Docker container installation with system monitoring capabilities",
dockerOneClickCommand: "Docker One-Click Command",
dockerScriptWill: "This script will automatically:",
dockerScriptStep1: "Download and setup the Docker monitoring agent",
dockerScriptStep2: "Configure all required environment variables",
dockerScriptStep3: "Start the container with proper system access",
dockerScriptStep4: "Setup monitoring data persistence",
directDockerTitle: "Direct Docker Run Command",
directDockerDesc: "If you prefer to run the Docker container directly without the script",
dockerRunCommand: "Docker Run Command",
dockerPrerequisites: "Prerequisites for direct Docker run:",
dockerPrereqStep1: "Docker must be installed and running",
dockerPrereqStep2: "The operacle/checkcle-server-agent image must be available",
dockerPrereqStep3: "Run as root or with sudo privileges",
// ManualInstallTab.tsx
manualInstallTitle: "Manual Installation Steps",
manualInstallDesc: "Step-by-step installation process",
serverName: "Server Name",
agentId: "Agent ID",
osType: "OS Type",
downloadScript: "Download the installation script",
makeExecutable: "Make the script executable",
runInstall: "Run the installation with your configuration",
prerequisites: "Prerequisites:",
prereqRoot: "Ensure you have root/sudo access on the target server",
prereqCurl: "Make sure curl is installed for downloading files",
prereqInternet: "Internet connection required for downloading script",
afterInstall: "After Installation:",
agentWillStart: "The agent will start automatically and appear in your dashboard within a few minutes.",
// ServerDetail.tsx
errorLoadingServer: "Error loading server",
unableToFetchServerData: "Unable to fetch server data. Please check your connection and try again.",
backToServers: "Back to Servers",
loadingServerDetails: "Loading server details...",
serverDetail: "Server Detail",
monitorServerMetrics: "Monitor server performance metrics and system health",
serverHostnameIpOs: "{hostname} • {ip_address} • {os_type}",
// ContainerMonitoring.tsx
errorLoadingContainers: "Error loading containers",
unableToFetchContainerData: "Unable to fetch container data. Please check your connection and try again.",
errorUnknown: "Unknown error",
containerMonitoring: "Container Monitoring",
monitorAndManageContainers: "Monitor and manage your Docker containers in real-time",
serverIdLabel: "Server ID",
}; };
+114
View File
@@ -60,4 +60,118 @@ export const settingsTranslations: SettingsTranslations = {
permissionNoticeAddUser: "As an admin user, you do not have access to view or modify system and mail settings. These settings can only be accessed and modified by Super Admins. Contact your Super Admin if you need to make changes to system configuration or mail settings.", permissionNoticeAddUser: "As an admin user, you do not have access to view or modify system and mail settings. These settings can only be accessed and modified by Super Admins. Contact your Super Admin if you need to make changes to system configuration or mail settings.",
loadingSettings: "Loading settings...", loadingSettings: "Loading settings...",
loadingSettingsError: "Error loading settings", loadingSettingsError: "Error loading settings",
//NotificationSettings.ts
titleNotification: "Notification Settings",
descriptionChannelsServices: "Configure notification channels for your services",
addChannel: "Add Channel",
all: "All Channels",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
signal: "Signal",
googleChat: "Google Chat",
email: "Email",
webhook: "Webhook",
matrix: "Matrix",
// NotificationChannelDialog.tsx
editChannel: "Edit Notification Channel",
addChannelDialog: "Add Notification Channel",
channelName: "Channel Name",
channelNameDesc: "A name to identify this notification channel",
channelType: "Channel Type",
selectType: "Select notification type",
enabled: "Enabled",
enabledDesc: "Enable or disable this notification channel",
cancel: "Cancel",
updateChannel: "Update Channel",
createChannel: "Create Channel",
payloadTemplates: "Payload Templates",
availablePlaceholders: "Available Placeholders:",
server: "Server",
service: "Service",
ssl: "SSL",
common: "Common",
webhookUrl: "Webhook URL",
webhookUrlDesc: "The URL where webhook notifications will be sent",
payloadTemplate: "Payload Template (Optional)",
payloadTemplateDesc: "JSON template for the webhook payload. Leave empty to use default template.",
telegramChatId: "Chat ID",
telegramChatIdDesc: "The Telegram chat ID to send notifications to",
botToken: "Bot Token",
botTokenDesc: "Your Telegram bot token from @BotFather",
discordWebhookUrl: "Webhook URL",
discordWebhookUrlDesc: "Discord webhook URL from your server settings",
slackWebhookUrl: "Webhook URL",
slackWebhookUrlDesc: "Slack incoming webhook URL",
signalNumber: "Signal Number",
signalNumberDesc: "Signal phone number to send notifications to",
signalApiEndpoint: "Signal API Endpoint",
signalApiEndpointDesc: "The Rest API endpoint for your Signal service",
googleChatWebhookUrl: "Google Chat Webhook URL",
googleChatWebhookUrlDesc: "Google Chat webhook URL from your Google Chat space",
emailAddress: "Email Address",
emailAddressDesc: "Email address to send notifications to",
emailSenderName: "Sender Name",
emailSenderNameDesc: "Display name for outgoing emails",
smtpServer: "SMTP Server",
// smtpPort: "SMTP Port",
// smtpPassword: "SMTP Password",
smtpPasswordDesc: "Password for authenticating with the SMTP server",
ntfyEndpoint: "NTFY Endpoint",
ntfyEndpointDesc: "The NTFY endpoint URL including your topic (e.g., https://ntfy.sh/checkcle)",
apiToken: "API Token",
apiTokenOptional: "API Token (Optional)",
apiTokenDesc: "Optional API token for authentication with NTFY server",
pushoverUserKey: "User Key",
pushoverUserKeyDesc: "Your Pushover user key (or group key)",
notifiarrChannelId: "Channel ID",
notifiarrChannelIdDesc: "The Discord channel ID where notifications will be sent",
gotifyServerUrl: "Server URL",
gotifyServerUrlDesc: "The URL of your Gotify server",
matrixHomeserver: "Homeserver URL",
matrixHomeserverDesc: "The URL of your Matrix homeserver (e.g. https://matrix.org)",
matrixRoomId: "Room ID",
matrixRoomIdDesc: "The Matrix room ID to send notifications to (e.g. !abc123:matrix.org)",
matrixAccessToken: "Access Token",
matrixAccessTokenDesc: "The access token of your Matrix bot account",
errorSaveChannel: "Failed to save notification channel",
channelNamePlaceholder: "My Notification Channel",
telegramChatIdPlaceholder: "Telegram Chat ID",
botTokenPlaceholder: "Telegram Bot Token",
discordWebhookUrlPlaceholder: "https://discord.com/api/webhooks/...",
slackWebhookUrlPlaceholder: "https://hooks.slack.com/services/...",
signalNumberPlaceholder: "+1234567890",
signalApiEndpointPlaceholder: "https://your-signal-api.com/v2/send",
googleChatWebhookUrlPlaceholder: "https://chat.googleapis.com/v1/spaces/...",
emailAddressPlaceholder: "notifications@example.com",
emailSenderNamePlaceholder: "Alert System",
smtpServerPlaceholder: "smtp.gmail.com",
smtpPortPlaceholder: "587",
smtpPasswordPlaceholder: "Enter your SMTP password",
ntfyEndpointPlaceholder: "https://ntfy.sh/your-topic",
apiTokenPlaceholder: "Enter API token",
pushoverUserKeyPlaceholder: "Your Pushover user key",
notifiarrChannelIdPlaceholder: "Discord Channel ID",
gotifyServerUrlPlaceholder: "https://your-gotify-server.com",
webhookUrlPlaceholder: "https://api.example.com/webhook",
matrixHomeserverPlaceholder: "https://matrix.org",
matrixRoomIdPlaceholder: "!roomid:matrix.org",
matrixAccessTokenPlaceholder: "syt_...",
// DataRetentionSettings.tsx
// permissionNotice: "Permission Notice:",
permissionNoticeDataRetention: "As an admin user, you do not have access to data retention settings. These settings can only be accessed and modified by Super Admins.",
loadingRetentionSettings: "Loading retention settings...",
dataRetention: "Data Retention Settings",
dataRetentionDescription: "Configure how long monitoring data is kept in the system",
uptimeRetentionLabel: "Uptime Monitoring Retention (days)",
uptimeRetentionHelp: "Service uptime and incident data older than this will be automatically deleted",
serverRetentionLabel: "Server Monitoring Retention (days)",
serverRetentionHelp: "Server metrics and process data older than this will be automatically deleted",
lastCleanup: "Last automatic cleanup",
// save: "Save Changes"
}; };
+1
View File
@@ -15,6 +15,7 @@ export const loginTranslations: LoginTranslations = {
loginSuccessMessage: "正常にログインしました。", loginSuccessMessage: "正常にログインしました。",
loginFailed: "ログイン失敗", loginFailed: "ログイン失敗",
authenticationFailed: "認証に失敗しました", authenticationFailed: "認証に失敗しました",
invalidCredentials: "メールアドレスまたはパスワードが正しくありません",
bySigningIn: "サインインすることで、", bySigningIn: "サインインすることで、",
termsAndConditions: "利用規約", termsAndConditions: "利用規約",
and: "と", and: "と",
+15
View File
@@ -16,4 +16,19 @@ export const aboutTranslations: AboutTranslations = {
quickActionsDescription: "ចូលប្រើប្រតិបត្តិការត្រួតពិនិត្យ និងមុខងារទូទៅយ៉ាងរហ័ស។ ជ្រើសរើសសកម្មភាពខាងក្រោមដើម្បីចាប់ផ្តើម។", quickActionsDescription: "ចូលប្រើប្រតិបត្តិការត្រួតពិនិត្យ និងមុខងារទូទៅយ៉ាងរហ័ស។ ជ្រើសរើសសកម្មភាពខាងក្រោមដើម្បីចាប់ផ្តើម។",
quickTips: "គន្លឹះរហ័ស", quickTips: "គន្លឹះរហ័ស",
releasedOn: "បានចេញផ្សាយនៅថ្ងៃទី", releasedOn: "បានចេញផ្សាយនៅថ្ងៃទី",
updateSchema: "ធ្វើបច្ចុប្បន្នភាព Schema",
updateSchemaDesc: "បញ្ចូល Schema នៃការប្រមូលដោយស្វ័យប្រវត្តិ",
mergeFieldsLabel: "បញ្ចូល fields ជាមួយនឹងការប្រមូលដែលមានស្រាប់ (សុវត្ថិភាព - រក្សាទិន្នន័យ)",
importing: "កំពុងបញ្ចូល...",
clickToUpdateSchema: "ចុចដើម្បីធ្វើបច្ចុប្បន្នភាព Schema",
importSuccessful: "បញ្ចូលជោគជ័យ",
importFailed: "បញ្ចូលបរាជ័យ",
instructions: "ការណែនាំ",
mergeFields: "បញ្ចូល fields",
instructionsMergeFields: "បន្ថែម fields ថ្មីទៅក្នុងការប្រមូលដែលមានស្រាប់ដោយសុវត្ថិភាព រក្សាទុកទិន្នន័យទាំងអស់",
instructionsCollections: "ការប្រមូលប្រព័ន្ធ (ចាប់ផ្តើមដោយ _) និងការប្រមូលអ្នកប្រើប្រាស់នឹងត្រូវរំលងដោយស្វ័យប្រវត្តិ",
instructionsImportAuth: "មានតែអ្នកគ្រប់គ្រងដែលបានផ្ទៀងផ្ទាត់អត្តសញ្ញាណប៉ុណ្ណោះអាចធ្វើការបញ្ចូល Schema",
collectionsUpdatedCount: "ការប្រមូលចំនួន {count} ត្រូវបានធ្វើបច្ចុប្បន្នភាព",
collectionsCreatedCount: "ការប្រមូលចំនួន {count} ត្រូវបានបង្កើត",
collectionsSkippedCount: "ការប្រមូលចំនួន {count} ត្រូវបានរំលង"
}; };
+71
View File
@@ -0,0 +1,71 @@
import { DockerTranslations } from '../types/docker';
export const dockerTranslations: DockerTranslations = {
dockerContainers: "Containers Docker",
container: "Containers",
status: "ស្ថានភាព",
cpuUsage: "ការប្រើប្រាស់ CPU",
memory: "អង្គចងចាំ",
disk: "ថាស",
uptime: "ពេលដំណើរការ",
lastChecked: "បានពិនិត្យចុងក្រោយ",
actions: "សកម្មភាព",
searchContainersPlaceholder: "ស្វែងរកContainers...",
refresh: "ធ្វើបច្ចុប្បន្នភាព",
openMenu: "បើកម៉ឺនុយ",
viewMetrics: "មើលមេត្រីក",
viewDetails: "មើលព័ត៌មានលម្អិត",
running: "ដំណើរការ",
stopped: "បានបញ្ឈប់",
warning: "ការព្រមាន",
unknown: "មិនស្គាល់",
totalContainers: "Containers សរុប",
containersLabel: "Containers",
noContainersFound: "រកមិនឃើញContainers",
noContainersRunning: "គ្មានContainersកំពុងដំណើរការ",
tryAdjustSearch: "សូមព្យាយាមកែសម្រួលពាក្យស្វែងរករបស់អ្នក។",
startSomeContainers: "ចាប់ផ្តើមContainersខ្លះៗ ដើម្បីមើលពួកវានៅទីនេះ។",
containerMetricsTitle: "មេត្រីកContainers: {name}",
dockerId: "Docker ID",
loadingMetrics: "កំពុងផ្ទុកមេត្រីក...",
errorLoadingMetrics: "កំហុសក្នុងការផ្ទុកមេត្រីក",
noMetricsAvailable: "គ្មានទិន្នន័យមេត្រីកសម្រាប់Containersនេះ",
minutes60: "៦០ នាទី",
day1: "១ ថ្ងៃ",
days7: "៧ ថ្ងៃ",
month1: "១ ខែ",
months3: "៣ ខែ",
used: "បានប្រើ:",
free: "ទំនេរ:",
total: "សរុប:",
usage: "ការប្រើប្រាស់:",
cpu: "CPU",
memoryTitle: "អង្គចងចាំ",
diskTitle: "ថាស",
network: "បណ្តាញ",
cpuUsagePct: "ការប្រើប្រាស់ CPU (%)",
cpuUsageVsAvailable: "ការប្រើប្រាស់ CPU នឹង អាចប្រើបាន",
cpuAvailablePct: "CPU អាចប្រើបាន (%)",
ramUsagePct: "ការប្រើប្រាស់ RAM (%)",
memoryUsageBytes: "ការប្រើប្រាស់អង្គចងចាំ (បៃ)",
usedMemory: "អង្គចងចាំបានប្រើ",
totalMemory: "អង្គចងចាំសរុប",
diskUsagePct: "ការប្រើប្រាស់ថាស (%)",
diskUsageBytes: "ការប្រើប្រាស់ថាស (បៃ)",
usedDisk: "ថាសបានប្រើ",
totalDisk: "ថាសសរុប",
networkTraffic: "ចរាចរបណ្តាញ",
networkSpeedKbs: "ល្បឿនបណ្តាញ (KB/s)",
rxBytes: "RX បៃ",
txBytes: "TX បៃ",
rxSpeedKbs: "ល្បឿន RX (KB/s)",
txSpeedKbs: "ល្បឿន TX (KB/s)",
};
@@ -53,4 +53,13 @@ export const incidentTranslations: IncidentTranslations = {
failedToUpdateStatus: "បរាជ័យក្នុងការធ្វើបច្ចុប្បន្នភាពស្ថានភាព", failedToUpdateStatus: "បរាជ័យក្នុងការធ្វើបច្ចុប្បន្នភាពស្ថានភាព",
inProgress: "កំពុងដំណើរការ", inProgress: "កំពុងដំណើរការ",
enterRootCause: 'បញ្ចូលហេតុផលដើម', enterRootCause: 'បញ្ចូលហេតុផលដើម',
enterIncidentTitle: "បញ្ចូលចំណងជើងឧបទ្ទវហេតុ",
enterIncidentDescription: "បញ្ចូលការពិពណ៌នាអំពីឧបទ្ទវហេតុ",
enterServiceId: "បញ្ចូលលេខសម្គាល់សេវាកម្ម ដោយបំបែកដោយសញ្ញាក្បៀស",
selectAssignedUser: "ជ្រើសរើសអ្នកប្រើប្រាស់ដែលត្រូវបានចាត់តាំង",
noAssignedUser: "ឧបទ្ទវហេតុបច្ចុប្បន្នមិនត្រូវបានចាត់តាំងទៅអ្នកប្រើប្រាស់ណាមួយទេ",
affectedSystems: "ប្រព័ន្ធដែលរងផលប៉ះពាល់",
enterAffectedSystems: "បញ្ចូលប្រព័ន្ធដែលរងផលប៉ះពាល់",
separateSystemsWithComma: "បញ្ចូលប្រព័ន្ធដែលរងផលប៉ះពាល់ ដោយបំបែកដោយសញ្ញាក្បៀស",
createIncidentDesc: "បង្កើតឧបទ្ទវហេតុថ្មី"
}; };
+12 -1
View File
@@ -9,6 +9,11 @@ import { maintenanceTranslations } from './maintenance';
import { incidentTranslations } from './incident'; import { incidentTranslations } from './incident';
import { sslTranslations } from './ssl'; import { sslTranslations } from './ssl';
import { settingsTranslations } from './settings'; import { settingsTranslations } from './settings';
import { instanceTranslations } from './instance';
import { operationTranslations } from './operation';
import { regionTranslations } from './region';
import { dockerTranslations } from './docker';
import { publicTranslations } from './public';
const enTranslations: Translations = { const enTranslations: Translations = {
common: commonTranslations, common: commonTranslations,
@@ -19,7 +24,13 @@ const enTranslations: Translations = {
maintenance: maintenanceTranslations, maintenance: maintenanceTranslations,
incident: incidentTranslations, incident: incidentTranslations,
ssl: sslTranslations, ssl: sslTranslations,
settings: settingsTranslations settings: settingsTranslations,
instance: instanceTranslations,
operation: operationTranslations,
region: regionTranslations,
docker: dockerTranslations,
public: publicTranslations
}; };
export default enTranslations; export default enTranslations;
+165
View File
@@ -0,0 +1,165 @@
import { InstanceTranslations } from '../types/instance';
export const instanceTranslations: InstanceTranslations = {
// InstanceMonitoring.tsx
instanceMonitoring: "ការត្រួតពិនិត្យឧទាហរណ៍",
describeMonitorInstance: "ត្រួតពិនិត្យនិងគ្រប់គ្រងឧទាហរណ៍ម៉ាស៊ីនមេរបស់អ្នកក្នុងពេលជាក់ស្តែង",
addServerAgent: "បន្ថែមភ្នាក់ងារម៉ាស៊ីនមេ",
errorLoadingServers: "កំហុសក្នុងការផ្ទុកម៉ាស៊ីនមេ",
unableToFetchServerData: "មិនអាចទាញយកទិន្នន័យម៉ាស៊ីនមេបានទេ។ សូមពិនិត្យការតភ្ជាប់របស់អ្នក ហើយព្យាយាមម្តងទៀត។",
retry: "ព្យាយាមម្តងទៀត",
// ServerTable.tsx
servers: "ម៉ាស៊ីនមេ",
loadingServers: "កំពុងផ្ទុកម៉ាស៊ីនមេ...",
searchServersPlaceholder: "ស្វែងរកម៉ាស៊ីនមេ...",
noServersFound: "រកមិនឃើញម៉ាស៊ីនមេ",
name: "ឈ្មោះ",
status: "ស្ថានភាព",
OS: "ប្រព័ន្ធប្រតិបត្តិការ",
IPAddress: "អាសយដ្ឋាន IP",
CPU: "ស៊ីភីយូ",
memory: "អង្គចងចាំ",
disk: "ថាស",
uptime: "ពេលវេលាដំណើរការ",
lastChecked: "បានពិនិត្យចុងក្រោយ",
actions: "សកម្មភាព",
viewServerDetail: "មើលព័ត៌មានលម្អិតម៉ាស៊ីនមេ",
containerMonitoring: "ការត្រួតពិនិត្យ Container",
resumeMonitoring: "បន្តការត្រួតពិនិត្យ",
pauseMonitoring: "ផ្អាកការត្រួតពិនិត្យ",
editServer: "កែសម្រួលម៉ាស៊ីនមេ",
deleteServer: "លុបម៉ាស៊ីនមេ",
deleteServerConfirmTitle: "តើអ្នកប្រាកដថាចង់លុបម៉ាស៊ីនមេនេះទេ?",
deleteServerConfirmDesc: "សកម្មភាពនេះមិនអាចត្រឡប់វិញបានទេ។ វានឹងលុប {name} និងទិន្នន័យត្រួតពិនិត្យទាំងអស់របស់វាជាអចិន្ត្រៃយ៍។",
cancel: "បោះបង់",
deleting: "កំពុងលុប...",
delete: "លុប",
serverDeleted: "ម៉ាស៊ីនមេត្រូវបានលុប",
serverDeletedDesc: "{name} ត្រូវបានលុបដោយជោគជ័យ។",
error: "កំហុស",
deleteServerError: "បរាជ័យក្នុងការលុបម៉ាស៊ីនមេ។ សូមព្យាយាមម្តងទៀត។",
serverPaused: "ម៉ាស៊ីនមេត្រូវបានផ្អាក",
serverResumed: "ម៉ាស៊ីនមេត្រូវបានបន្ត",
monitoringPaused: "ការត្រួតពិនិត្យត្រូវបានផ្អាកសម្រាប់ {name}",
monitoringResumed: "ការត្រួតពិនិត្យត្រូវបានបន្តសម្រាប់ {name}",
pauseServerError: "បរាជ័យក្នុងការផ្អាកការត្រួតពិនិត្យម៉ាស៊ីនមេ។ សូមព្យាយាមម្តងទៀត។",
resumeServerError: "បរាជ័យក្នុងការបន្តការត្រួតពិនិត្យម៉ាស៊ីនមេ। សូមព្យាយាមម្តងទៀត។",
// ServerStatsCards.tsx
totalServers: "ម៉ាស៊ីនមេសរុប",
onlineServers: "ម៉ាស៊ីនមេដំណើរការ",
offlineServers: "ម៉ាស៊ីនមេផ្អាកដំណើរការ",
warningServers: "ម៉ាស៊ីនមេដែលមានការព្រមាន",
// AddServerAgentDialog.tsx
addServerMonitoringAgent: "បន្ថែមភ្នាក់ងារត្រួតពិនិត្យម៉ាស៊ីនមេ",
configureAgentDesc: "កំណត់រចនាសម្ព័ន្ធភ្នាក់ងារត្រួតពិនិត្យម៉ាស៊ីនមេថ្មីដើម្បីតាមដានម៉ែត្រប្រព័ន្ធ និងប្រសិទ្ធភាព។",
configureAgent: "កំណត់រចនាសម្ព័ន្ធភ្នាក់ងារ",
oneClickInstall: "ការដំឡើងតែមួយចុច",
dockerOneClick: "Docker ការដំឡើងតែមួយចុច",
manualInstallation: "ការដំឡើងដោយដៃ",
validationError: "កំហុសក្នុងការផ្ទៀងផ្ទាត់",
fillRequiredFields: "សូមបំពេញចន្លោះទាំងអស់ដែលតម្រូវឱ្យមាន។",
serverAgentCreated: "ភ្នាក់ងារម៉ាស៊ីនមេត្រូវបានបង្កើត",
serverAgentCreatedDesc: "ភ្នាក់ងារត្រួតពិនិត្យ {name} ត្រូវបានកំណត់រចនាសម្ព័ន្ធដោយជោគជ័យ។",
failedToCreateAgent: "បរាជ័យក្នុងការបង្កើតភ្នាក់ងារត្រួតពិនិត្យម៉ាស៊ីនមេ។",
// ServerAgentConfigForm.tsx
serverName: "ឈ្មោះម៉ាស៊ីនមេ",
serverNamePlaceholder: "ឧ. web-server-01",
serverNameDesc: "ឈ្មោះ ឬស្លាកដែលប្រើជាអត្តសញ្ញាណ",
serverAgentId: "លេខសម្គាល់ភ្នាក់ងារម៉ាស៊ីនមេ",
serverAgentIdDesc: "លេខសម្គាល់តែមួយគត់ដែលបង្កើតដោយស្វ័យប្រវត្តិ",
operatingSystem: "ប្រព័ន្ធប្រតិបត្តិការ",
checkInterval: "ចន្លោះពេលពិនិត្យ",
selectInterval: "ជ្រើសរើសចន្លោះពេល",
interval30s: "៣០ វិនាទី",
interval1m: "១ នាទី",
interval2m: "២ នាទី",
interval5m: "៥ នាទី",
checkIntervalDesc: "ភាពញឹកញាប់នៃការពិនិត្យស្ថានភាពម៉ាស៊ីនមេ និងម៉ែត្រ",
retryAttempts: "ការព្យាយាមឡើងវិញ",
selectRetryAttempts: "ជ្រើសរើសចំនួនការព្យាយាមឡើងវិញ",
attempt1: "១ ការព្យាយាម",
attempt2: "២ ការព្យាយាម",
attempt3: "៣ ការព្យាយាម",
attempt5: "៥ ការព្យាយាម",
retryAttemptsDesc: "ចំនួនការព្យាយាមឡើងវិញមុនពេលសម្គាល់ថាដាច់",
serverToken: "សញ្ញាសម្ងាត់ម៉ាស៊ីនមេ",
serverTokenDesc: "សញ្ញាសម្ងាត់ផ្ទៀងផ្ទាត់ដែលបង្កើតដោយស្វ័យប្រវត្តិ",
systemUrl: "URL ប្រព័ន្ធ",
systemUrlDesc: "URL API ប្រព័ន្ធបច្ចុប្បន្ន",
creatingAgent: "កំពុងបង្កើតភ្នាក់ងារ...",
createServerAgent: "បង្កើតភ្នាក់ងារម៉ាស៊ីនមេ",
// EditServerDialog.tsx
editServerConfiguration: "កែសម្រួលការកំណត់រចនាសម្ព័ន្ធម៉ាស៊ីនមេ",
serverNameLabel: "ឈ្មោះម៉ាស៊ីនមេ *",
enterServerNamePlaceholder: "បញ្ចូលឈ្មោះម៉ាស៊ីនមេ",
checkIntervalLabel: "ចន្លោះពេលពិនិត្យ",
// selectInterval: "ជ្រើសរើសចន្លោះពេល",
interval10m: "១០ នាទី",
maxRetriesLabel: "ការព្យាយាមឡើងវិញអតិបរិមា",
selectMaxRetries: "ជ្រើសរើសការព្យាយាមអតិបរិមា",
retry1: "១ ដង",
retry2: "២ ដង",
retry3: "៣ ដង",
retry5: "៥ ដង",
retry10: "១០ ដង",
dockerMonitoring: "ការត្រួតពិនិត្យ Docker",
enabled: "បានបើក",
disabled: "បានបិទ",
enableNotifications: "បើកការជូនដំណឹង",
notificationSettings: "ការកំណត់ការជូនដំណឹង",
notificationChannels: "ឆានែលការជូនដំណឹង",
loadingChannels: "កំពុងផ្ទុកឆានែល...",
noChannelsAvailable: "មិនមានឆានែលការជូនដំណឹង",
selectedChannels: "ឆានែលដែលបានជ្រើស:",
serverSetThreshold: "កំណត់កម្រិតម៉ាស៊ីនមេ",
loadingThresholds: "កំពុងផ្ទុកកម្រិត...",
selectServerThreshold: "ជ្រើសរើសកម្រិតម៉ាស៊ីនមេ",
noThreshold: "គ្មានកម្រិត (ប្រើលំនាំដើម)",
thresholdDetails: "ព័ត៌មានលម្អិតអំពីកម្រិត",
cpuThresholdPct: "កម្រិត CPU (%)",
ramThresholdPct: "កម្រិត RAM (%)",
diskThresholdPct: "កម្រិតថាស (%)",
networkThresholdPct: "កម្រិតបណ្តាញ (%)",
serverTemplate: "គម្រូម៉ាស៊ីនមេ",
loadingTemplates: "កំពុងផ្ទុកគម្រូ...",
selectServerTemplate: "ជ្រើសរើសគម្រូម៉ាស៊ីនមេ",
noTemplate: "គ្មានគម្រូ (ប្រើលំនាំដើម)",
templateDetails: "ព័ត៌មានលម្អិតអំពីគម្រូ",
ramMessage: "សារ RAM",
cpuMessage: "សារ CPU",
diskMessage: "សារ ថាស",
networkMessage: "សារ បណ្តាញ",
upMessage: "សារដំណើរការ",
downMessage: "សារផ្អាក",
noMessageDefined: "គ្មានសារសម្រាប់ {name}",
serverUpdated: "បានធ្វើបច្ចុប្បន្នភាពម៉ាស៊ីនមេ",
serverUpdatedDesc: "{name} ត្រូវបានធ្វើបច្ចុប្បន្នភាពដោយជោគជ័យ។",
updating: "កំពុងធ្វើបច្ចុប្បន្នភាព...",
updateServer: "ធ្វើបច្ចុប្បន្នភាពម៉ាស៊ីនមេ",
failedToLoadChannels: "បានបរាជ័យក្នុងការផ្ទុកឆានែលការជូនដំណឹង",
failedToLoadTemplates: "បានបរាជ័យក្នុងការផ្ទុកគម្រូ",
failedToLoadThresholds: "បានបរាជ័យក្នុងការផ្ទុកកម្រិតម៉ាស៊ីនមេ",
warning: "ការព្រមាន",
serverUpdatedButThresholdFailed: "ម៉ាស៊ីនមេត្រូវបានធ្វើបច្ចុប្បន្នភាព ប៉ុន្តែបរាជ័យក្នុងការធ្វើបច្ចុប្បន្នភាពតម្លៃកម្រិត។",
failedToUpdateServer: "បរាជ័យក្នុងការធ្វើបច្ចុប្បន្នភាពម៉ាស៊ីនមេ។ សូមព្យាយាមម្តងទៀត।",
// ServerDetail.tsx
historicalPerformance: "សម្ដែងប្រវត្តិប្រតិបត្តិការ",
loading: "កំពុងផ្ទុក...",
errorLoadingChartData: "មានបញ្ហាក្នុងការផ្ទុកទិន្នន័យតារាង",
noHistoricalData: "មិនមានទិន្នន័យចាស់សម្រាប់ {{timeRange}}",
rawMetricsCount: "ចំនួនមេត្រដើម៖ {{count}}",
serverIdTimeRange: "លេខសម្គាល់ម៉ាស៊ីនបម្រើ៖ {{serverId}} • ជួរពេលវេលា៖ {{timeRange}}",
dataExistsOutsideRange: "ទិន្នន័យមាន ប៉ុន្តែអាចនៅខាងក្រៅជួរពេលវេលាដែលបានជ្រើស",
noMetricsDataFound: "រកមិនឃើញទិន្នន័យមេត្រទេ",
dataPointsTimeRange: "{{count}} ចំណុចទិន្នន័យ • {{timeRange}}",
// updating: "កំពុងធ្វើបច្ចុប្បន្នភាព...",
};
+1
View File
@@ -16,6 +16,7 @@ export const loginTranslations: LoginTranslations = {
loginSuccessMessage: "អ្នកបានចូលប្រើដោយជោគជ័យ។", loginSuccessMessage: "អ្នកបានចូលប្រើដោយជោគជ័យ។",
loginFailed: "ការចូលបានបរាជ័យ", loginFailed: "ការចូលបានបរាជ័យ",
authenticationFailed: "ការផ្ទៀងផ្ទាត់បានបរាជ័យ", authenticationFailed: "ការផ្ទៀងផ្ទាត់បានបរាជ័យ",
invalidCredentials: "អ៊ីមែល ឬពាក្យសម្ងាត់មិនត្រឹមត្រូវ",
bySigningIn: "ដោយការចូល អ្នកយល់ព្រមនឹង", bySigningIn: "ដោយការចូល អ្នកយល់ព្រមនឹង",
termsAndConditions: "លក្ខខណ្ឌ", termsAndConditions: "លក្ខខណ្ឌ",
and: "និង", and: "និង",
@@ -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: "គ្មានសេវាកម្ម",
}; };
+4 -4
View File
@@ -2,13 +2,13 @@
import { MenuTranslations } from '../types/menu'; import { MenuTranslations } from '../types/menu';
export const menuTranslations: MenuTranslations = { export const menuTranslations: MenuTranslations = {
uptimeMonitoring: "ការត្រួតពិនិត្យ Uptime", uptimeMonitoring: "ត្រួតពិនិត្យ Uptime",
instanceMonitoring: "ការត្រួតពិនិត្យឧបករណ៍", instanceMonitoring: "ត្រួតពិនិត្យម៉ាស៊ីនមេ",
sslDomain: "SSL និងដមែន", sslDomain: "SSL និងដមែន",
scheduleIncident: "កាលវិភាគនិងឧបទ្ទវហេតុ", scheduleIncident: "កាលវិភាគនិងឧបទ្ទវហេតុ",
operationalPage: "ទំព័រប្រតិបត្តិការ", operationalPage: "ទំព័រប្រតិបត្តិការ",
reports: "របាយការណ៍", reports: "របាយការណ៍",
regionalMonitoring: "ការត្រួតពិនិត្យតំបន់", regionalMonitoring: "ត្រួតពិនិត្យតំបន់",
settingPanel: "ផ្ទាំងការកំណត់", settingPanel: "ផ្ទាំងការកំណត់",
generalSettings: "ការកំណត់ទូទៅ", generalSettings: "ការកំណត់ទូទៅ",
userManagement: "ការគ្រប់គ្រងអ្នកប្រើប្រាស់", userManagement: "ការគ្រប់គ្រងអ្នកប្រើប្រាស់",
@@ -0,0 +1,86 @@
import { OperationTranslations } from '../types/operation';
export const operationTranslations: OperationTranslations = {
// OperationalPageContent.tsx
failedToLoadOperationalPages: "បរាជ័យក្នុងការផ្ទុកទំព័រប្រតិបត្តិការ",
loadingoperationalPages: "មានបញ្ហាក្នុងការផ្ទុកទំព័រប្រតិបត្តិការរបស់អ្នក។ សូមព្យាយាមម្តងទៀត។",
tryagain: "ព្យាយាមម្តងទៀត",
operationalPages: "ទំព័រប្រតិបត្តិការ",
describeOperation: "គ្រប់គ្រងទំព័រស្ថានភាពសាធារណៈរបស់អ្នក និងត្រួតពិនិត្យសុខភាពសេវាកម្ម",
refresh: "ធ្វើបច្ចុប្បន្នភាព",
noOperationalPagesFound: "រកមិនឃើញទំព័រប្រតិបត្តិការទេ",
createYourFirstOperationalPage: "បង្កើតទំព័រប្រតិបត្តិការដំបូងរបស់អ្នក ដើម្បីចាប់ផ្តើមត្រួតពិនិត្យសេវាកម្មរបស់អ្នក និងប្រាស្រ័យទាក់ទងស្ថានភាពទៅកាន់អ្នកប្រើប្រាស់។",
totalPages: "ទំព័រសរុប",
publicPages: "ទំព័រសាធារណៈ",
operational: "ប្រតិបត្តិការ",
deleteOperationalPage: "លុបទំព័រប្រតិបត្តិការ",
deleteOperationalPageConfirm: 'តើអ្នកប្រាកដថាចង់លុប "{title}" ឬ? សកម្មភាពនេះមិនអាចត្រឡប់វិញបានទេ ហើយនឹងលុបទំព័រប្រតិបត្តិការ និងសមាសធាតុទាំងអស់របស់វាជាអចិន្ត្រៃយ៍។',
cancel: "បោះបង់",
delete: "លុប",
deleting: "កំពុងលុប...",
// EditOperationalPageDialog.tsx
editOperationalPage: "កែសម្រួលទំព័រប្រតិបត្តិការ",
updateYourOperationalPage: "ធ្វើបច្ចុប្បន្នភាពការកំណត់ទំព័រស្ថានភាពប្រតិបត្តិការរបស់អ្នក និងគ្រប់គ្រងសមាសធាតុ។",
title: "ចំណងជើង",
myServiceStatusPlaceholder: "ស្ថានភាពសេវាកម្មរបស់ខ្ញុំ",
slug: "ចំនងជើង URL",
myServiceStatusSlugPlaceholder: "ស្ថានភាពសេវាកម្មរបស់ខ្ញុំ",
description: "ការពិពណ៌នា",
operationalPageDescriptionPlaceholder: "ការពិពណ៌នាខ្លីអំពីទំព័រប្រតិបត្តិការ​របស់អ្នក",
theme: "ផ្ទៃខាងក្រោយ",
selectTheme: "ជ្រើសរើសផ្ទៃខាងក្រោយ",
themeDefault: "លំនាំដើម",
themeDark: "ងងឹត",
themeLight: "ភ្លឺ",
status: "ស្ថានភាព",
selectStatus: "ជ្រើសរើសស្ថានភាព",
statusOperational: "ប្រតិបត្តិការ",
statusDegraded: "ការសមត្ថភាពធ្លាក់ចុះ",
statusMaintenance: "នៅក្រោមការថែទាំ",
statusMajorOutage: "ការបាត់បង់ធំ",
publicPage: "ទំព័រសាធារណៈ",
makePagePublic: "ធ្វើឱ្យទំព័រនេះអាចចូលដំណើរការបានសាធារណៈ",
customDomainOptional: "ដែនកំណត់ផ្ទាល់ខ្លួន (ជាជម្រើស)",
customDomainPlaceholder: "status.yourdomain.com",
customDomainDescription: "ដែនកំណត់ផ្ទាល់ខ្លួនសម្រាប់ទំព័រស្ថានភាពរបស់អ្នក",
cancelUpdate: "បោះបង់",
updating: "កំពុងធ្វើបច្ចុប្បន្នភាព...",
updatePage: "ធ្វើបច្ចុប្បន្នភាពទំព័រ",
// ComponentsSelector.tsx
statusPageComponents: "សមាសធាតុទំព័រស្ថានភាព",
addMonitoringComponentsDesc: "បន្ថែមសមាសធាតុត្រួតពិនិត្យដូចជាសេវាកម្មពេលវេលាដំណើរការ, វិញ្ញាបនបត្រ SSL, និងការតាមដានឧប្បត្តិហេតុ",
selectedComponents: "សមាសធាតុដែលបានជ្រើសរើស",
service: "សេវាកម្ម",
server: "ម៉ាស៊ីនមេ",
addComponent: "បន្ថែមសមាសធាតុ",
componentName: "ឈ្មោះសមាសធាតុ",
componentNamePlaceholder: "ឧ. គេហទំព័រមេ",
displayOrder: "លំដាប់បង្ហាញ",
descriptionOptional: "ការពិពណ៌នា (ស្រេចចិត្ត)",
descriptionPlaceholder: "ការពិពណ៌នាសង្ខេបអំពីសមាសធាតុនេះ",
uptimeServiceOptional: "សេវាកម្មពេលវេលាដំណើរការ (ស្រេចចិត្ត)",
selectUptimeService: "ជ្រើសរើសសេវាកម្មពេលវេលាដំណើរការ",
serverIdOptional: "លេខសម្គាល់ម៉ាស៊ីនមេ (ស្រេចចិត្ត)",
serverIdPlaceholder: "server_456",
cancelComponent: "បោះបង់",
// OperationalPageCard.tsx
public: "សាធារណៈ",
yes: "បាទ/ចាស",
no: "ទេ",
updated: "បានធ្វើបច្ចុប្បន្នភាព",
view: "មើល",
edit: "កែសម្រួល",
// CreateOperationalPageDialog.tsx
createOperationalPage: "បង្កើតទំព័រប្រតិបត្តិការ",
createOperationalPageDesc: "បង្កើតទំព័រស្ថានភាពប្រតិបត្តិការថ្មីដើម្បីត្រួតពិនិត្យសេវាកម្ម និងសមាសធាតុរបស់អ្នក។",
createPage: "បង្កើតទំព័រ",
creating: "កំពុងបង្កើត...",
initialStatus: "ស្ថានភាពដំបូង",
};

Some files were not shown because too many files have changed in this diff Show More