mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-26 09:33:40 +00:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ebcdd57d5 | ||
|
|
a3d0dfd665 | ||
|
|
d99ded6d65 | ||
|
|
1ea96b6172 | ||
|
|
1e5ee66825 | ||
|
|
88130797e4 | ||
|
|
566c415471 | ||
|
|
cfc91243eb | ||
|
|
84cf31869b | ||
|
|
18c9d241eb | ||
|
|
86b5da3ea0 | ||
|
|
c9b5ee63d8 | ||
|
|
ac4415e1dc | ||
|
|
3737a5a935 | ||
|
|
bcce48948a | ||
|
|
5e4c628110 | ||
|
|
a8668ee3f3 | ||
|
|
5487206384 | ||
|
|
daa31973f9 | ||
|
|
561c78fb08 | ||
|
|
6d3f2d94ba | ||
|
|
93534ebe52 | ||
|
|
5cf2811bfd | ||
|
|
8fd91eae1a | ||
|
|
da8c661d20 | ||
|
|
2bf639e315 | ||
|
|
c02ac4bd6f | ||
|
|
4e0eaf7323 | ||
|
|
ef9ef58bcb | ||
|
|
29afe3da1f | ||
|
|
a861e4f9eb | ||
|
|
12ef6fd8e1 | ||
|
|
ba9de097dc | ||
|
|
8103581d17 | ||
|
|
cdb24520d8 | ||
|
|
831adf3038 | ||
|
|
2a1eed1354 | ||
|
|
7819d4512e | ||
|
|
a305fe23d3 | ||
|
|
2b36e88d85 | ||
|
|
6624ec002d | ||
|
|
840779844a | ||
|
|
f91d3324ba | ||
|
|
8c60b5277e | ||
|
|
2ac756af84 | ||
|
|
e227004d6b | ||
|
|
d379473568 | ||
|
|
2edc773adf | ||
|
|
2db839556c | ||
|
|
aab6fc244e | ||
|
|
811f5b5885 | ||
|
|
b43c9e94fd | ||
|
|
2e2a554aa3 | ||
|
|
eabcfd370c | ||
|
|
55cb07b3c8 | ||
|
|
0e049ec3d5 | ||
|
|
a2464fac5c | ||
|
|
5dc3e8ba81 | ||
|
|
63817b450f | ||
|
|
1fa0502d7d | ||
|
|
581dc5884c | ||
|
|
dcaffe2805 | ||
|
|
a3005bccb4 | ||
|
|
499ef9d5d9 | ||
|
|
6eb6ea3fd6 | ||
|
|
a27c607d9e | ||
|
|
d4e0abd407 | ||
|
|
8d447cab0d | ||
|
|
6988ecab12 | ||
|
|
fd108c6a21 | ||
|
|
3ea8cc74b6 | ||
|
|
a43fc9d380 | ||
|
|
864719b4b3 | ||
|
|
cc89df161b | ||
|
|
2659a930d6 | ||
|
|
fa57b35270 | ||
|
|
766d36ff80 | ||
|
|
3a76d54707 | ||
|
|
dd28e741d4 | ||
|
|
35d3c28ae5 | ||
|
|
3cf2ada84e | ||
|
|
b25bba50a7 | ||
|
|
811930d1e2 | ||
|
|
f3db16d6d0 | ||
|
|
b3887c818d | ||
|
|
f7b73ba280 | ||
|
|
5c2bacb322 | ||
|
|
657017801b | ||
|
|
5e8cfa6b63 | ||
|
|
f9bd56215d | ||
|
|
aa8b42cbb0 | ||
|
|
51f6fabd45 | ||
|
|
32ab004f3f | ||
|
|
71b27b4bcf | ||
|
|
60ca2064bf | ||
|
|
5ccd0aa163 | ||
|
|
a13b4941cd | ||
|
|
482a9e27c9 | ||
|
|
f085596b87 | ||
|
|
757feab9cd | ||
|
|
fffc571453 | ||
|
|
6f59a1981d | ||
|
|
8bb16f0896 | ||
|
|
b454b8d130 | ||
|
|
3fc4b799be | ||
|
|
9c39d83fe5 | ||
|
|
2ce6d9cd73 | ||
|
|
e97ccc5cbd | ||
|
|
373ef8f468 | ||
|
|
513c268b36 | ||
|
|
13c4342135 | ||
|
|
bbb97dbfda | ||
|
|
e0eb544205 | ||
|
|
51982010db | ||
|
|
dc68afcb87 | ||
|
|
bec09b9457 | ||
|
|
55c8f74b73 | ||
|
|
16ea1dc743 | ||
|
|
8c326c8fe2 | ||
|
|
2abc9b1f8a | ||
|
|
e5f3b0ed26 | ||
|
|
bfc5db11da | ||
|
|
a0bea9b6e5 | ||
|
|
ebda7331a9 | ||
|
|
9963cfa417 | ||
|
|
4e6a9829cf | ||
|
|
b99f4aad4e | ||
|
|
7a8e9d95a0 |
12
.github/workflows/app_build.yml
vendored
12
.github/workflows/app_build.yml
vendored
@@ -1,10 +1,10 @@
|
||||
name: Build on Merge
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths-ignore:
|
||||
- 'docker/**'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -15,3 +15,11 @@ jobs:
|
||||
|
||||
- name: Run rebuild script
|
||||
run: /root/patchmon/platform/scripts/app_build.sh ${{ github.ref_name }}
|
||||
|
||||
rebuild-pmon:
|
||||
runs-on: self-hosted
|
||||
needs: deploy
|
||||
if: github.ref_name == 'dev'
|
||||
steps:
|
||||
- name: Rebuild pmon
|
||||
run: /root/patchmon/platform/scripts/manage_pmon_auto.sh
|
||||
|
||||
4
.github/workflows/code_quality.yml
vendored
4
.github/workflows/code_quality.yml
vendored
@@ -2,7 +2,11 @@ name: Code quality
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'docker/**'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'docker/**'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
|
||||
17
.github/workflows/docker.yml
vendored
17
.github/workflows/docker.yml
vendored
@@ -1,13 +1,14 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
@@ -56,7 +57,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=edge,branch=main
|
||||
|
||||
- name: Build and push ${{ matrix.image }} image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -64,7 +65,11 @@ jobs:
|
||||
context: .
|
||||
file: docker/${{ matrix.image }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'workflow_dispatch' || inputs.push == 'true' }}
|
||||
# Push if:
|
||||
# - Event is not workflow_dispatch OR input 'push' is true
|
||||
# AND
|
||||
# - Event is not pull_request OR the PR is from the same repository (to avoid pushing from forks)
|
||||
push: ${{ (github.event_name != 'workflow_dispatch' || inputs.push == 'true') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ matrix.image }}
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -71,6 +71,13 @@ jspm_packages/
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Exception: Allow frontend/public/assets for logo files
|
||||
!frontend/public/
|
||||
!frontend/public/assets/
|
||||
!frontend/public/assets/*.png
|
||||
!frontend/public/assets/*.svg
|
||||
!frontend/public/assets/*.jpg
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
@@ -147,4 +154,4 @@ setup-installer-site.sh
|
||||
install-server.*
|
||||
notify-clients-upgrade.sh
|
||||
debug-agent.sh
|
||||
docker/compose_dev_data
|
||||
docker/compose_dev_*
|
||||
|
||||
48
README.md
48
README.md
@@ -4,6 +4,8 @@
|
||||
[](https://patchmon.net/discord)
|
||||
[](https://github.com/9technologygroup/patchmon.net)
|
||||
[](https://github.com/users/9technologygroup/projects/1)
|
||||
[](https://docs.patchmon.net/)
|
||||
|
||||
---
|
||||
|
||||
## Please STAR this repo :D
|
||||
@@ -12,7 +14,7 @@
|
||||
|
||||
PatchMon provides centralized patch management across diverse server environments. Agents communicate outbound-only to the PatchMon server, eliminating inbound ports on monitored hosts while delivering comprehensive visibility and safe automation.
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
@@ -41,6 +43,7 @@ PatchMon provides centralized patch management across diverse server environment
|
||||
|
||||
### API & Integrations
|
||||
- REST API under `/api/v1` with JWT auth
|
||||
- Proxmox LXC Auto-Enrollment - Automatically discover and enroll LXC containers from Proxmox hosts
|
||||
|
||||
### Security
|
||||
- Rate limiting for general, auth, and agent endpoints
|
||||
@@ -62,7 +65,7 @@ Managed, zero-maintenance PatchMon hosting. Stay tuned.
|
||||
|
||||
#### Docker (preferred)
|
||||
|
||||
For getting started with Docker, see the [Docker documentation](https://github.com/9technologygroup/patchmon.net/blob/main/docker/README.md)
|
||||
For getting started with Docker, see the [Docker documentation](https://github.com/PatchMon/PatchMon/blob/main/docker/README.md)
|
||||
|
||||
#### Native Install (advanced/non-docker)
|
||||
|
||||
@@ -82,9 +85,14 @@ apt-get upgrade -y
|
||||
apt install curl -y
|
||||
```
|
||||
|
||||
#### Script
|
||||
#### Install Script
|
||||
```bash
|
||||
curl -fsSL -o setup.sh https://raw.githubusercontent.com/9technologygroup/patchmon.net/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh
|
||||
curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh
|
||||
```
|
||||
|
||||
#### Update Script (--update flag)
|
||||
```bash
|
||||
curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh --update
|
||||
```
|
||||
|
||||
#### Minimum specs for building : #####
|
||||
@@ -110,6 +118,14 @@ After installation:
|
||||
- Visit `http(s)://<your-domain>` and complete first-time admin setup
|
||||
- See all useful info in `deployment-info.txt`
|
||||
|
||||
## Forcing updates after host package changes
|
||||
Should you perform a manual package update on your host and wish to see the results reflected in PatchMon quicker than the usual scheduled update, you can trigger the process manually by running:
|
||||
```bash
|
||||
/usr/local/bin/patchmon-agent.sh update
|
||||
```
|
||||
|
||||
This will send the results immediately to PatchMon.
|
||||
|
||||
## Communication Model
|
||||
|
||||
- Outbound-only agents: servers initiate communication to PatchMon
|
||||
@@ -124,22 +140,18 @@ After installation:
|
||||
- Database: PostgreSQL
|
||||
- System service: systemd-managed backend
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[End Users / Browser<br>Admin UI / Frontend] -- HTTPS --> B[nginx<br>serve FE, proxy API]
|
||||
B -- HTTP --> C["Backend<br>(Node/Express)<br>/api, auth, Prisma"]
|
||||
C -- TCP --> D[PostgreSQL<br>Database]
|
||||
|
||||
E["Agents on your servers (Outbound Only)"] -- HTTPS --> F["Backend API<br>(/api/v1)"]
|
||||
```
|
||||
+----------------------+ HTTPS +--------------------+ HTTP +------------------------+ TCP +---------------+
|
||||
| End Users (Browser) | ---------> | nginx | --------> | Backend (Node/Express) | ------> | PostgreSQL |
|
||||
| Admin UI / Frontend | | serve FE, proxy API| | /api, auth, Prisma | | Database |
|
||||
+----------------------+ +--------------------+ +------------------------+ +---------------+
|
||||
|
||||
Agents (Outbound Only)
|
||||
+---------------------------+ HTTPS +------------------------+
|
||||
| Agents on your servers | ----------> | Backend API (/api/v1) |
|
||||
+---------------------------+ +------------------------+
|
||||
|
||||
Operational
|
||||
- systemd manages backend service
|
||||
- certbot/nginx for TLS (public)
|
||||
- setup.sh bootstraps OS, app, DB, config
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
@@ -148,7 +160,7 @@ Operational
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Roadmap board: https://github.com/users/9technologygroup/projects/1
|
||||
- Roadmap board: https://github.com/orgs/PatchMon/projects/2
|
||||
|
||||
|
||||
## License
|
||||
@@ -271,7 +283,7 @@ Thank you to all our contributors who help make PatchMon better every day!
|
||||
- **Website**: [patchmon.net](https://patchmon.net)
|
||||
- **Discord**: [https://patchmon.net/discord](https://patchmon.net/discord)
|
||||
- **Roadmap**: [GitHub Projects](https://github.com/users/9technologygroup/projects/1)
|
||||
- **Documentation**: [Coming Soon]
|
||||
- **Documentation**: [https://docs.patchmon.net](https://docs.patchmon.net)
|
||||
- **Support**: support@patchmon.net
|
||||
|
||||
---
|
||||
@@ -281,6 +293,6 @@ Thank you to all our contributors who help make PatchMon better every day!
|
||||
**Made with ❤️ by the PatchMon Team**
|
||||
|
||||
[](https://patchmon.net/discord)
|
||||
[](https://github.com/9technologygroup/patchmon.net)
|
||||
[](https://github.com/PatchMon/PatchMon)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PatchMon Agent Script v1.2.7
|
||||
# PatchMon Agent Script v1.2.8
|
||||
# This script sends package update information to the PatchMon server using API credentials
|
||||
|
||||
# Configuration
|
||||
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
|
||||
API_VERSION="v1"
|
||||
AGENT_VERSION="1.2.7"
|
||||
AGENT_VERSION="1.2.8"
|
||||
CONFIG_FILE="/etc/patchmon/agent.conf"
|
||||
CREDENTIALS_FILE="/etc/patchmon/credentials"
|
||||
LOG_FILE="/var/log/patchmon-agent.log"
|
||||
@@ -56,6 +56,28 @@ warning() {
|
||||
log "WARNING: $1"
|
||||
}
|
||||
|
||||
# Get or generate machine ID
|
||||
get_machine_id() {
|
||||
# Try standard locations for machine-id
|
||||
if [[ -f /etc/machine-id ]]; then
|
||||
cat /etc/machine-id
|
||||
elif [[ -f /var/lib/dbus/machine-id ]]; then
|
||||
cat /var/lib/dbus/machine-id
|
||||
else
|
||||
# Fallback: generate from hardware UUID or hostname+MAC
|
||||
if command -v dmidecode &> /dev/null; then
|
||||
local uuid=$(dmidecode -s system-uuid 2>/dev/null | tr -d ' -' | tr '[:upper:]' '[:lower:]')
|
||||
if [[ -n "$uuid" && "$uuid" != "notpresent" ]]; then
|
||||
echo "$uuid"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
# Last resort: hash hostname + primary MAC address
|
||||
local primary_mac=$(ip link show | grep -oP '(?<=link/ether\s)[0-9a-f:]+' | head -1 | tr -d ':')
|
||||
echo "$HOSTNAME-$primary_mac" | sha256sum | cut -d' ' -f1 | cut -c1-32
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
@@ -209,9 +231,14 @@ detect_os() {
|
||||
"opensuse"|"opensuse-leap"|"opensuse-tumbleweed")
|
||||
OS_TYPE="suse"
|
||||
;;
|
||||
"rocky"|"almalinux")
|
||||
"almalinux")
|
||||
OS_TYPE="rhel"
|
||||
;;
|
||||
"ol")
|
||||
# Keep Oracle Linux as 'ol' for proper frontend identification
|
||||
OS_TYPE="ol"
|
||||
;;
|
||||
# Rocky Linux keeps its own identity for proper frontend display
|
||||
esac
|
||||
|
||||
elif [[ -f /etc/redhat-release ]]; then
|
||||
@@ -239,7 +266,7 @@ get_repository_info() {
|
||||
"ubuntu"|"debian")
|
||||
get_apt_repositories repos_json first
|
||||
;;
|
||||
"centos"|"rhel"|"fedora")
|
||||
"centos"|"rhel"|"fedora"|"ol"|"rocky")
|
||||
get_yum_repositories repos_json first
|
||||
;;
|
||||
*)
|
||||
@@ -547,14 +574,118 @@ get_yum_repositories() {
|
||||
local -n first_ref=$2
|
||||
|
||||
# Parse yum/dnf repository configuration
|
||||
local repo_info=""
|
||||
if command -v dnf >/dev/null 2>&1; then
|
||||
local repo_info=$(dnf repolist all --verbose 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-name|^Repo-status")
|
||||
repo_info=$(dnf repolist all --verbose 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-mirrors|^Repo-name|^Repo-status")
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
local repo_info=$(yum repolist all -v 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-name|^Repo-status")
|
||||
repo_info=$(yum repolist all -v 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-mirrors|^Repo-name|^Repo-status")
|
||||
fi
|
||||
|
||||
# This is a simplified implementation - would need more work for full YUM support
|
||||
# For now, return empty for non-APT systems
|
||||
if [[ -z "$repo_info" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Parse repository information
|
||||
local current_repo=""
|
||||
local repo_id=""
|
||||
local repo_name=""
|
||||
local repo_url=""
|
||||
local repo_mirrors=""
|
||||
local repo_status=""
|
||||
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^Repo-id[[:space:]]+:[[:space:]]+(.+)$ ]]; then
|
||||
# Process previous repository if we have one
|
||||
if [[ -n "$current_repo" ]]; then
|
||||
process_yum_repo repos_ref first_ref "$repo_id" "$repo_name" "$repo_url" "$repo_mirrors" "$repo_status"
|
||||
fi
|
||||
|
||||
# Start new repository
|
||||
repo_id="${BASH_REMATCH[1]}"
|
||||
repo_name="$repo_id"
|
||||
repo_url=""
|
||||
repo_mirrors=""
|
||||
repo_status=""
|
||||
current_repo="$repo_id"
|
||||
|
||||
elif [[ "$line" =~ ^Repo-name[[:space:]]+:[[:space:]]+(.+)$ ]]; then
|
||||
repo_name="${BASH_REMATCH[1]}"
|
||||
|
||||
elif [[ "$line" =~ ^Repo-baseurl[[:space:]]+:[[:space:]]+(.+)$ ]]; then
|
||||
repo_url="${BASH_REMATCH[1]}"
|
||||
|
||||
elif [[ "$line" =~ ^Repo-mirrors[[:space:]]+:[[:space:]]+(.+)$ ]]; then
|
||||
repo_mirrors="${BASH_REMATCH[1]}"
|
||||
|
||||
elif [[ "$line" =~ ^Repo-status[[:space:]]+:[[:space:]]+(.+)$ ]]; then
|
||||
repo_status="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
done <<< "$repo_info"
|
||||
|
||||
# Process the last repository
|
||||
if [[ -n "$current_repo" ]]; then
|
||||
process_yum_repo repos_ref first_ref "$repo_id" "$repo_name" "$repo_url" "$repo_mirrors" "$repo_status"
|
||||
fi
|
||||
}
|
||||
|
||||
# Process a single YUM repository and add it to the JSON
|
||||
process_yum_repo() {
|
||||
local -n _repos_ref=$1
|
||||
local -n _first_ref=$2
|
||||
local repo_id="$3"
|
||||
local repo_name="$4"
|
||||
local repo_url="$5"
|
||||
local repo_mirrors="$6"
|
||||
local repo_status="$7"
|
||||
|
||||
# Skip if we don't have essential info
|
||||
if [[ -z "$repo_id" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Determine if repository is enabled
|
||||
local is_enabled=false
|
||||
if [[ "$repo_status" == "enabled" ]]; then
|
||||
is_enabled=true
|
||||
fi
|
||||
|
||||
# Use baseurl if available, otherwise use mirrors URL
|
||||
local final_url=""
|
||||
if [[ -n "$repo_url" ]]; then
|
||||
# Extract first URL if multiple are listed
|
||||
final_url=$(echo "$repo_url" | head -n 1 | awk '{print $1}')
|
||||
elif [[ -n "$repo_mirrors" ]]; then
|
||||
final_url="$repo_mirrors"
|
||||
fi
|
||||
|
||||
# Skip if we don't have any URL
|
||||
if [[ -z "$final_url" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# Determine if repository uses HTTPS
|
||||
local is_secure=false
|
||||
if [[ "$final_url" =~ ^https:// ]]; then
|
||||
is_secure=true
|
||||
fi
|
||||
|
||||
# Generate repository name if not provided
|
||||
if [[ -z "$repo_name" ]]; then
|
||||
repo_name="$repo_id"
|
||||
fi
|
||||
|
||||
# Clean up repository name and URL - escape quotes and backslashes
|
||||
repo_name=$(echo "$repo_name" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||
final_url=$(echo "$final_url" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
|
||||
|
||||
# Add to JSON
|
||||
if [[ "$_first_ref" == true ]]; then
|
||||
_first_ref=false
|
||||
else
|
||||
_repos_ref+=","
|
||||
fi
|
||||
|
||||
_repos_ref+="{\"name\":\"$repo_name\",\"url\":\"$final_url\",\"distribution\":\"$OS_VERSION\",\"components\":\"main\",\"repoType\":\"rpm\",\"isEnabled\":$is_enabled,\"isSecure\":$is_secure}"
|
||||
}
|
||||
|
||||
# Get package information based on OS
|
||||
@@ -566,11 +697,11 @@ get_package_info() {
|
||||
"ubuntu"|"debian")
|
||||
get_apt_packages packages_json first
|
||||
;;
|
||||
"centos"|"rhel"|"fedora")
|
||||
"centos"|"rhel"|"fedora"|"ol"|"rocky")
|
||||
get_yum_packages packages_json first
|
||||
;;
|
||||
*)
|
||||
error "Unsupported OS type: $OS_TYPE"
|
||||
warning "Unsupported OS type: $OS_TYPE - returning empty package list"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -583,8 +714,24 @@ get_apt_packages() {
|
||||
local -n packages_ref=$1
|
||||
local -n first_ref=$2
|
||||
|
||||
# Update package lists (use apt-get for older distros; quieter output)
|
||||
apt-get update -qq
|
||||
# Update package lists with retry logic for lock conflicts
|
||||
local retry_count=0
|
||||
local max_retries=3
|
||||
local retry_delay=5
|
||||
|
||||
while [[ $retry_count -lt $max_retries ]]; do
|
||||
if apt-get update -qq 2>/dev/null; then
|
||||
break
|
||||
else
|
||||
retry_count=$((retry_count + 1))
|
||||
if [[ $retry_count -lt $max_retries ]]; then
|
||||
warning "APT lock detected, retrying in ${retry_delay} seconds... (attempt $retry_count/$max_retries)"
|
||||
sleep $retry_delay
|
||||
else
|
||||
warning "APT lock persists after $max_retries attempts, continuing without update..."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Determine upgradable packages using apt-get simulation (compatible with Ubuntu 18.04)
|
||||
# Example line format:
|
||||
@@ -604,6 +751,11 @@ get_apt_packages() {
|
||||
is_security_update=true
|
||||
fi
|
||||
|
||||
# Escape JSON special characters in package data
|
||||
package_name=$(echo "$package_name" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
|
||||
current_version=$(echo "$current_version" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
|
||||
available_version=$(echo "$available_version" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
|
||||
|
||||
if [[ "$first_ref" == true ]]; then
|
||||
first_ref=false
|
||||
else
|
||||
@@ -615,12 +767,16 @@ get_apt_packages() {
|
||||
done <<< "$upgradable_sim"
|
||||
|
||||
# Get installed packages that are up to date
|
||||
local installed=$(dpkg-query -W -f='${Package} ${Version}\n' | head -100)
|
||||
local installed=$(dpkg-query -W -f='${Package} ${Version}\n')
|
||||
|
||||
while IFS=' ' read -r package_name version; do
|
||||
if [[ -n "$package_name" && -n "$version" ]]; then
|
||||
# Check if this package is not in the upgrade list
|
||||
if ! echo "$upgradable" | grep -q "^$package_name/"; then
|
||||
if ! echo "$upgradable_sim" | grep -q "^Inst $package_name "; then
|
||||
# Escape JSON special characters in package data
|
||||
package_name=$(echo "$package_name" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
|
||||
version=$(echo "$version" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
|
||||
|
||||
if [[ "$first_ref" == true ]]; then
|
||||
first_ref=false
|
||||
else
|
||||
@@ -686,7 +842,7 @@ get_yum_packages() {
|
||||
done <<< "$upgradable"
|
||||
|
||||
# Get some installed packages that are up to date
|
||||
local installed=$($package_manager list installed 2>/dev/null | grep -v "^Loaded" | grep -v "^Installed" | head -100)
|
||||
local installed=$($package_manager list installed 2>/dev/null | grep -v "^Loaded" | grep -v "^Installed")
|
||||
|
||||
while IFS= read -r line; do
|
||||
# Skip empty lines
|
||||
@@ -849,6 +1005,9 @@ get_system_info() {
|
||||
send_update() {
|
||||
load_credentials
|
||||
|
||||
# Track execution start time
|
||||
local start_time=$(date +%s.%N)
|
||||
|
||||
# Verify datetime before proceeding
|
||||
if ! verify_datetime; then
|
||||
warning "Datetime verification failed, but continuing with update..."
|
||||
@@ -861,10 +1020,26 @@ send_update() {
|
||||
local network_json=$(get_network_info)
|
||||
local system_json=$(get_system_info)
|
||||
|
||||
# Validate JSON before sending
|
||||
if ! echo "$packages_json" | jq empty 2>/dev/null; then
|
||||
error "Invalid packages JSON generated: $packages_json"
|
||||
fi
|
||||
|
||||
if ! echo "$repositories_json" | jq empty 2>/dev/null; then
|
||||
error "Invalid repositories JSON generated: $repositories_json"
|
||||
fi
|
||||
|
||||
info "Sending update to PatchMon server..."
|
||||
|
||||
# Merge all JSON objects into one
|
||||
local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]')
|
||||
# Get machine ID
|
||||
local machine_id=$(get_machine_id)
|
||||
|
||||
# Calculate execution time (in seconds with decimals)
|
||||
local end_time=$(date +%s.%N)
|
||||
local execution_time=$(echo "$end_time - $start_time" | bc)
|
||||
|
||||
# Create the base payload and merge with system info
|
||||
local base_payload=$(cat <<EOF
|
||||
{
|
||||
@@ -875,7 +1050,9 @@ send_update() {
|
||||
"hostname": "$HOSTNAME",
|
||||
"ip": "$IP_ADDRESS",
|
||||
"architecture": "$ARCHITECTURE",
|
||||
"agentVersion": "$AGENT_VERSION"
|
||||
"agentVersion": "$AGENT_VERSION",
|
||||
"machineId": "$machine_id",
|
||||
"executionTime": $execution_time
|
||||
}
|
||||
EOF
|
||||
)
|
||||
@@ -883,15 +1060,27 @@ EOF
|
||||
# Merge the base payload with the system information
|
||||
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
|
||||
|
||||
# Write payload to temporary file to avoid "Argument list too long" error
|
||||
local temp_payload_file=$(mktemp)
|
||||
echo "$payload" > "$temp_payload_file"
|
||||
|
||||
# Debug: Show payload size
|
||||
local payload_size=$(wc -c < "$temp_payload_file")
|
||||
echo -e "${BLUE}ℹ️ 📊 Payload size: $payload_size bytes${NC}"
|
||||
|
||||
local response=$(curl $CURL_FLAGS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-ID: $API_ID" \
|
||||
-H "X-API-KEY: $API_KEY" \
|
||||
-d "$payload" \
|
||||
"$PATCHMON_SERVER/api/$API_VERSION/hosts/update")
|
||||
-d @"$temp_payload_file" \
|
||||
"$PATCHMON_SERVER/api/$API_VERSION/hosts/update" 2>&1)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
local curl_exit_code=$?
|
||||
|
||||
# Clean up temporary file
|
||||
rm -f "$temp_payload_file"
|
||||
|
||||
if [[ $curl_exit_code -eq 0 ]]; then
|
||||
if echo "$response" | grep -q "success"; then
|
||||
local packages_count=$(echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2)
|
||||
success "Update sent successfully (${packages_count} packages processed)"
|
||||
@@ -927,7 +1116,7 @@ EOF
|
||||
error "Update failed: $response"
|
||||
fi
|
||||
else
|
||||
error "Failed to send update"
|
||||
error "Failed to send update (curl exit code: $curl_exit_code): $response"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -109,14 +109,39 @@ cleanup_old_files() {
|
||||
# Run cleanup at start
|
||||
cleanup_old_files
|
||||
|
||||
# Generate or retrieve machine ID
|
||||
get_machine_id() {
|
||||
# Try multiple sources for machine ID
|
||||
if [[ -f /etc/machine-id ]]; then
|
||||
cat /etc/machine-id
|
||||
elif [[ -f /var/lib/dbus/machine-id ]]; then
|
||||
cat /var/lib/dbus/machine-id
|
||||
else
|
||||
# Fallback: generate from hardware info (less ideal but works)
|
||||
echo "patchmon-$(cat /sys/class/dmi/id/product_uuid 2>/dev/null || cat /proc/sys/kernel/random/uuid)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse arguments from environment (passed via HTTP headers)
|
||||
if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then
|
||||
error "Missing required parameters. This script should be called via the PatchMon web interface."
|
||||
fi
|
||||
|
||||
# Check if --force flag is set (for bypassing broken packages)
|
||||
FORCE_INSTALL="${FORCE_INSTALL:-false}"
|
||||
if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||
FORCE_INSTALL="true"
|
||||
warning "⚠️ Force mode enabled - will bypass broken packages"
|
||||
fi
|
||||
|
||||
# Get unique machine ID for this host
|
||||
MACHINE_ID=$(get_machine_id)
|
||||
export MACHINE_ID
|
||||
|
||||
info "🚀 Starting PatchMon Agent Installation..."
|
||||
info "📋 Server: $PATCHMON_URL"
|
||||
info "🔑 API ID: ${API_ID:0:16}..."
|
||||
info "🆔 Machine ID: ${MACHINE_ID:0:16}..."
|
||||
|
||||
# Display diagnostic information
|
||||
echo ""
|
||||
@@ -131,16 +156,88 @@ echo ""
|
||||
info "📦 Installing required dependencies..."
|
||||
echo ""
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Function to install packages with error handling
|
||||
install_apt_packages() {
|
||||
local packages=("$@")
|
||||
local missing_packages=()
|
||||
|
||||
# Check which packages are missing
|
||||
for pkg in "${packages[@]}"; do
|
||||
if ! command_exists "$pkg"; then
|
||||
missing_packages+=("$pkg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_packages[@]} -eq 0 ]; then
|
||||
success "All required packages are already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Need to install: ${missing_packages[*]}"
|
||||
|
||||
# Build apt-get command based on force mode
|
||||
local apt_cmd="apt-get install ${missing_packages[*]} -y"
|
||||
|
||||
if [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||
info "Using force mode - bypassing broken packages..."
|
||||
apt_cmd="$apt_cmd -o APT::Get::Fix-Broken=false -o DPkg::Options::=\"--force-confold\" -o DPkg::Options::=\"--force-confdef\""
|
||||
fi
|
||||
|
||||
# Try to install packages
|
||||
if eval "$apt_cmd" 2>&1 | tee /tmp/patchmon_apt_install.log; then
|
||||
success "Packages installed successfully"
|
||||
return 0
|
||||
else
|
||||
warning "Package installation encountered issues, checking if required tools are available..."
|
||||
|
||||
# Verify critical dependencies are actually available
|
||||
local all_ok=true
|
||||
for pkg in "${packages[@]}"; do
|
||||
if ! command_exists "$pkg"; then
|
||||
if [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||
error "Critical dependency '$pkg' is not available even with --force. Please install manually."
|
||||
else
|
||||
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apt-get install $pkg"
|
||||
fi
|
||||
all_ok=false
|
||||
fi
|
||||
done
|
||||
|
||||
if $all_ok; then
|
||||
success "All required tools are available despite installation warnings"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect package manager and install jq and curl
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
# Debian/Ubuntu
|
||||
info "Detected apt-get (Debian/Ubuntu)"
|
||||
echo ""
|
||||
|
||||
# Check for broken packages
|
||||
if dpkg -l | grep -q "^iH\|^iF" 2>/dev/null; then
|
||||
if [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||
warning "Detected broken packages on system - force mode will work around them"
|
||||
else
|
||||
warning "⚠️ Broken packages detected on system"
|
||||
warning "If installation fails, retry with: curl -s {URL}/api/v1/hosts/install --force -H ..."
|
||||
fi
|
||||
fi
|
||||
|
||||
info "Updating package lists..."
|
||||
apt-get update
|
||||
apt-get update || true
|
||||
echo ""
|
||||
info "Installing jq, curl, and bc..."
|
||||
apt-get install jq curl bc -y
|
||||
install_apt_packages jq curl bc
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
# CentOS/RHEL 7
|
||||
info "Detected yum (CentOS/RHEL 7)"
|
||||
@@ -261,6 +358,33 @@ if [[ -f "/var/log/patchmon-agent.log" ]]; then
|
||||
fi
|
||||
|
||||
# Step 4: Test the configuration
|
||||
# Check if this machine is already enrolled
|
||||
info "🔍 Checking if machine is already enrolled..."
|
||||
existing_check=$(curl $CURL_FLAGS -s -X POST \
|
||||
-H "X-API-ID: $API_ID" \
|
||||
-H "X-API-KEY: $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"machine_id\": \"$MACHINE_ID\"}" \
|
||||
"$PATCHMON_URL/api/v1/hosts/check-machine-id" \
|
||||
-w "\n%{http_code}" 2>&1)
|
||||
|
||||
http_code=$(echo "$existing_check" | tail -n 1)
|
||||
response_body=$(echo "$existing_check" | sed '$d')
|
||||
|
||||
if [[ "$http_code" == "200" ]]; then
|
||||
already_enrolled=$(echo "$response_body" | jq -r '.exists' 2>/dev/null || echo "false")
|
||||
if [[ "$already_enrolled" == "true" ]]; then
|
||||
warning "⚠️ This machine is already enrolled in PatchMon"
|
||||
info "Machine ID: $MACHINE_ID"
|
||||
info "Existing host: $(echo "$response_body" | jq -r '.host.friendly_name' 2>/dev/null)"
|
||||
info ""
|
||||
info "The agent will be reinstalled/updated with existing credentials."
|
||||
echo ""
|
||||
else
|
||||
success "✅ Machine not yet enrolled - proceeding with installation"
|
||||
fi
|
||||
fi
|
||||
|
||||
info "🧪 Testing API credentials and connectivity..."
|
||||
if /usr/local/bin/patchmon-agent.sh test; then
|
||||
success "✅ TEST: API credentials are valid and server is reachable"
|
||||
|
||||
437
agents/proxmox_auto_enroll.sh
Executable file
437
agents/proxmox_auto_enroll.sh
Executable file
@@ -0,0 +1,437 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail # Exit on error, pipe failures (removed -u as we handle unset vars explicitly)
|
||||
|
||||
# Trap to catch errors only (not normal exits)
|
||||
trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR
|
||||
|
||||
SCRIPT_VERSION="2.0.0"
|
||||
echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))"
|
||||
|
||||
# =============================================================================
|
||||
# PatchMon Proxmox LXC Auto-Enrollment Script
|
||||
# =============================================================================
|
||||
# This script discovers LXC containers on a Proxmox host and automatically
|
||||
# enrolls them into PatchMon for patch management.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Set environment variables or edit configuration below
|
||||
# 2. Run: bash proxmox_auto_enroll.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - Must run on Proxmox host (requires 'pct' command)
|
||||
# - Auto-enrollment token from PatchMon
|
||||
# - Network access to PatchMon server
|
||||
# =============================================================================
|
||||
|
||||
# ===== CONFIGURATION =====
|
||||
PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}"
|
||||
AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}"
|
||||
AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}"
|
||||
CURL_FLAGS="${CURL_FLAGS:--s}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
HOST_PREFIX="${HOST_PREFIX:-}"
|
||||
SKIP_STOPPED="${SKIP_STOPPED:-true}"
|
||||
PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}"
|
||||
MAX_PARALLEL="${MAX_PARALLEL:-5}"
|
||||
FORCE_INSTALL="${FORCE_INSTALL:-false}"
|
||||
|
||||
# ===== COLOR OUTPUT =====
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ===== LOGGING FUNCTIONS =====
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; return 0; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; return 0; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; return 0; }
|
||||
debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; return 0; }
|
||||
|
||||
# ===== BANNER =====
|
||||
cat << "EOF"
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ____ _ _ __ __ ║
|
||||
║ | _ \ __ _| |_ ___| |__ | \/ | ___ _ __ ║
|
||||
║ | |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \ ║
|
||||
║ | __/ (_| | || (__| | | | | | | (_) | | | | ║
|
||||
║ |_| \__,_|\__\___|_| |_|_| |_|\___/|_| |_| ║
|
||||
║ ║
|
||||
║ Proxmox LXC Auto-Enrollment Script ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
EOF
|
||||
echo ""
|
||||
|
||||
# ===== VALIDATION =====
|
||||
info "Validating configuration..."
|
||||
|
||||
if [[ -z "$AUTO_ENROLLMENT_KEY" ]] || [[ -z "$AUTO_ENROLLMENT_SECRET" ]]; then
|
||||
error "AUTO_ENROLLMENT_KEY and AUTO_ENROLLMENT_SECRET must be set"
|
||||
fi
|
||||
|
||||
if [[ -z "$PATCHMON_URL" ]]; then
|
||||
error "PATCHMON_URL must be set"
|
||||
fi
|
||||
|
||||
# Check if running on Proxmox
|
||||
if ! command -v pct &> /dev/null; then
|
||||
error "This script must run on a Proxmox host (pct command not found)"
|
||||
fi
|
||||
|
||||
# Check for required commands
|
||||
for cmd in curl jq; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
error "Required command '$cmd' not found. Please install it first."
|
||||
fi
|
||||
done
|
||||
|
||||
info "Configuration validated successfully"
|
||||
info "PatchMon Server: $PATCHMON_URL"
|
||||
info "Dry Run Mode: $DRY_RUN"
|
||||
info "Skip Stopped Containers: $SKIP_STOPPED"
|
||||
echo ""
|
||||
|
||||
# ===== DISCOVER LXC CONTAINERS =====
|
||||
info "Discovering LXC containers..."
|
||||
lxc_list=$(pct list | tail -n +2) # Skip header
|
||||
|
||||
if [[ -z "$lxc_list" ]]; then
|
||||
warn "No LXC containers found on this Proxmox host"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count containers
|
||||
total_containers=$(echo "$lxc_list" | wc -l)
|
||||
info "Found $total_containers LXC container(s)"
|
||||
echo ""
|
||||
|
||||
info "Initializing statistics..."
|
||||
# ===== STATISTICS =====
|
||||
enrolled_count=0
|
||||
skipped_count=0
|
||||
failed_count=0
|
||||
|
||||
# Track containers with dpkg errors for later recovery
|
||||
declare -A dpkg_error_containers
|
||||
|
||||
# Track all failed containers for summary
|
||||
declare -A failed_containers
|
||||
info "Statistics initialized"
|
||||
|
||||
# ===== PROCESS CONTAINERS =====
|
||||
info "Starting container processing loop..."
|
||||
while IFS= read -r line; do
|
||||
info "[DEBUG] Read line from lxc_list"
|
||||
vmid=$(echo "$line" | awk '{print $1}')
|
||||
status=$(echo "$line" | awk '{print $2}')
|
||||
name=$(echo "$line" | awk '{print $3}')
|
||||
|
||||
info "Processing LXC $vmid: $name (status: $status)"
|
||||
|
||||
# Skip stopped containers if configured
|
||||
if [[ "$status" != "running" ]] && [[ "$SKIP_STOPPED" == "true" ]]; then
|
||||
warn " Skipping $name - container not running"
|
||||
((skipped_count++)) || true
|
||||
echo ""
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if container is stopped
|
||||
if [[ "$status" != "running" ]]; then
|
||||
warn " Container $name is stopped - cannot gather info or install agent"
|
||||
((skipped_count++)) || true
|
||||
echo ""
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get container details
|
||||
debug " Gathering container information..."
|
||||
hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null </dev/null || echo "$name")
|
||||
ip_address=$(timeout 5 pct exec "$vmid" -- hostname -I 2>/dev/null </dev/null | awk '{print $1}' || echo "unknown")
|
||||
os_info=$(timeout 5 pct exec "$vmid" -- cat /etc/os-release 2>/dev/null </dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown")
|
||||
|
||||
# Get machine ID from container
|
||||
machine_id=$(timeout 5 pct exec "$vmid" -- bash -c "cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null || echo 'proxmox-lxc-$vmid-'$(cat /proc/sys/kernel/random/uuid)" </dev/null 2>/dev/null || echo "proxmox-lxc-$vmid-unknown")
|
||||
|
||||
friendly_name="${HOST_PREFIX}${hostname}"
|
||||
|
||||
info " Hostname: $hostname"
|
||||
info " IP Address: $ip_address"
|
||||
info " OS: $os_info"
|
||||
info " Machine ID: ${machine_id:0:16}..."
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
info " [DRY RUN] Would enroll: $friendly_name"
|
||||
((enrolled_count++)) || true
|
||||
echo ""
|
||||
continue
|
||||
fi
|
||||
|
||||
# Call PatchMon auto-enrollment API
|
||||
info " Enrolling $friendly_name in PatchMon..."
|
||||
|
||||
response=$(curl $CURL_FLAGS -X POST \
|
||||
-H "X-Auto-Enrollment-Key: $AUTO_ENROLLMENT_KEY" \
|
||||
-H "X-Auto-Enrollment-Secret: $AUTO_ENROLLMENT_SECRET" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"friendly_name\": \"$friendly_name\",
|
||||
\"machine_id\": \"$machine_id\",
|
||||
\"metadata\": {
|
||||
\"vmid\": \"$vmid\",
|
||||
\"proxmox_node\": \"$(hostname)\",
|
||||
\"ip_address\": \"$ip_address\",
|
||||
\"os_info\": \"$os_info\"
|
||||
}
|
||||
}" \
|
||||
"$PATCHMON_URL/api/v1/auto-enrollment/enroll" \
|
||||
-w "\n%{http_code}" 2>&1)
|
||||
|
||||
http_code=$(echo "$response" | tail -n 1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [[ "$http_code" == "201" ]]; then
|
||||
api_id=$(echo "$body" | jq -r '.host.api_id' 2>/dev/null || echo "")
|
||||
api_key=$(echo "$body" | jq -r '.host.api_key' 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$api_id" ]] || [[ -z "$api_key" ]]; then
|
||||
error " Failed to parse API credentials from response"
|
||||
fi
|
||||
|
||||
info " ✓ Host enrolled successfully: $api_id"
|
||||
|
||||
# Ensure curl is installed in the container
|
||||
info " Checking for curl in container..."
|
||||
curl_check=$(timeout 10 pct exec "$vmid" -- bash -c "command -v curl >/dev/null 2>&1 && echo 'installed' || echo 'missing'" 2>/dev/null </dev/null || echo "error")
|
||||
|
||||
if [[ "$curl_check" == "missing" ]]; then
|
||||
info " Installing curl in container..."
|
||||
|
||||
# Detect package manager and install curl
|
||||
curl_install_output=$(timeout 60 pct exec "$vmid" -- bash -c "
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq && apt-get install -y -qq curl
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y -q curl
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y -q curl
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache curl
|
||||
else
|
||||
echo 'ERROR: No supported package manager found'
|
||||
exit 1
|
||||
fi
|
||||
" 2>&1 </dev/null) || true
|
||||
|
||||
if [[ "$curl_install_output" == *"ERROR: No supported package manager"* ]]; then
|
||||
warn " ✗ Could not install curl - no supported package manager found"
|
||||
failed_containers["$vmid"]="$friendly_name|No package manager for curl|$curl_install_output"
|
||||
((failed_count++)) || true
|
||||
echo ""
|
||||
sleep 1
|
||||
continue
|
||||
else
|
||||
info " ✓ curl installed successfully"
|
||||
fi
|
||||
else
|
||||
info " ✓ curl already installed"
|
||||
fi
|
||||
|
||||
# Install PatchMon agent in container
|
||||
info " Installing PatchMon agent..."
|
||||
|
||||
# Build install URL with force flag if enabled
|
||||
install_url="$PATCHMON_URL/api/v1/hosts/install"
|
||||
if [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||
install_url="$install_url?force=true"
|
||||
info " Using force mode - will bypass broken packages"
|
||||
fi
|
||||
|
||||
# Reset exit code for this container
|
||||
install_exit_code=0
|
||||
|
||||
# Download and execute in separate steps to avoid stdin issues with piping
|
||||
install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
|
||||
cd /tmp
|
||||
curl $CURL_FLAGS \
|
||||
-H \"X-API-ID: $api_id\" \
|
||||
-H \"X-API-KEY: $api_key\" \
|
||||
-o patchmon-install.sh \
|
||||
'$install_url' && \
|
||||
bash patchmon-install.sh && \
|
||||
rm -f patchmon-install.sh
|
||||
" 2>&1 </dev/null) || install_exit_code=$?
|
||||
|
||||
# Check both exit code AND success message in output for reliability
|
||||
if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
|
||||
info " ✓ Agent installed successfully in $friendly_name"
|
||||
((enrolled_count++)) || true
|
||||
elif [[ $install_exit_code -eq 124 ]]; then
|
||||
warn " ⏱ Agent installation timed out (>180s) in $friendly_name"
|
||||
info " Install output: $install_output"
|
||||
# Store failure details
|
||||
failed_containers["$vmid"]="$friendly_name|Timeout (>180s)|$install_output"
|
||||
((failed_count++)) || true
|
||||
else
|
||||
# Check if it's a dpkg error
|
||||
if [[ "$install_output" == *"dpkg was interrupted"* ]] || [[ "$install_output" == *"dpkg --configure -a"* ]]; then
|
||||
warn " ⚠ Failed due to dpkg error in $friendly_name (can be fixed)"
|
||||
dpkg_error_containers["$vmid"]="$friendly_name:$api_id:$api_key"
|
||||
# Store failure details
|
||||
failed_containers["$vmid"]="$friendly_name|dpkg error|$install_output"
|
||||
else
|
||||
warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)"
|
||||
# Store failure details
|
||||
failed_containers["$vmid"]="$friendly_name|Exit code $install_exit_code|$install_output"
|
||||
fi
|
||||
info " Install output: $install_output"
|
||||
((failed_count++)) || true
|
||||
fi
|
||||
|
||||
elif [[ "$http_code" == "409" ]]; then
|
||||
warn " ⊘ Host $friendly_name already enrolled - skipping"
|
||||
((skipped_count++)) || true
|
||||
elif [[ "$http_code" == "429" ]]; then
|
||||
error " ✗ Rate limit exceeded - maximum hosts per day reached"
|
||||
failed_containers["$vmid"]="$friendly_name|Rate limit exceeded|$body"
|
||||
((failed_count++)) || true
|
||||
else
|
||||
error " ✗ Failed to enroll $friendly_name - HTTP $http_code"
|
||||
debug " Response: $body"
|
||||
failed_containers["$vmid"]="$friendly_name|HTTP $http_code enrollment failed|$body"
|
||||
((failed_count++)) || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
sleep 1 # Rate limiting between containers
|
||||
|
||||
done <<< "$lxc_list"
|
||||
|
||||
# ===== SUMMARY =====
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ENROLLMENT SUMMARY ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
info "Total Containers Found: $total_containers"
|
||||
info "Successfully Enrolled: $enrolled_count"
|
||||
info "Skipped: $skipped_count"
|
||||
info "Failed: $failed_count"
|
||||
echo ""
|
||||
|
||||
# ===== FAILURE DETAILS =====
|
||||
if [[ ${#failed_containers[@]} -gt 0 ]]; then
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ FAILURE DETAILS ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
for vmid in "${!failed_containers[@]}"; do
|
||||
IFS='|' read -r name reason output <<< "${failed_containers[$vmid]}"
|
||||
|
||||
warn "Container $vmid: $name"
|
||||
info " Reason: $reason"
|
||||
info " Last 5 lines of output:"
|
||||
|
||||
# Get last 5 lines of output
|
||||
last_5_lines=$(echo "$output" | tail -n 5)
|
||||
|
||||
# Display each line with proper indentation
|
||||
while IFS= read -r line; do
|
||||
echo " $line"
|
||||
done <<< "$last_5_lines"
|
||||
|
||||
echo ""
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
warn "This was a DRY RUN - no actual changes were made"
|
||||
warn "Set DRY_RUN=false to perform actual enrollment"
|
||||
fi
|
||||
|
||||
# ===== DPKG ERROR RECOVERY =====
|
||||
if [[ ${#dpkg_error_containers[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ DPKG ERROR RECOVERY AVAILABLE ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
warn "Detected ${#dpkg_error_containers[@]} container(s) with dpkg errors:"
|
||||
for vmid in "${!dpkg_error_containers[@]}"; do
|
||||
IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
|
||||
info " • Container $vmid: $name"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Ask user if they want to fix dpkg errors
|
||||
read -p "Would you like to fix dpkg errors and retry installation? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo ""
|
||||
info "Starting dpkg recovery process..."
|
||||
echo ""
|
||||
|
||||
recovered_count=0
|
||||
|
||||
for vmid in "${!dpkg_error_containers[@]}"; do
|
||||
IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
|
||||
|
||||
info "Fixing dpkg in container $vmid ($name)..."
|
||||
|
||||
# Run dpkg --configure -a
|
||||
dpkg_output=$(timeout 60 pct exec "$vmid" -- dpkg --configure -a 2>&1 </dev/null || true)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
info " ✓ dpkg fixed successfully"
|
||||
|
||||
# Retry agent installation
|
||||
info " Retrying agent installation..."
|
||||
|
||||
install_exit_code=0
|
||||
install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
|
||||
cd /tmp
|
||||
curl $CURL_FLAGS \
|
||||
-H \"X-API-ID: $api_id\" \
|
||||
-H \"X-API-KEY: $api_key\" \
|
||||
-o patchmon-install.sh \
|
||||
'$PATCHMON_URL/api/v1/hosts/install' && \
|
||||
bash patchmon-install.sh && \
|
||||
rm -f patchmon-install.sh
|
||||
" 2>&1 </dev/null) || install_exit_code=$?
|
||||
|
||||
if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
|
||||
info " ✓ Agent installed successfully in $name"
|
||||
((recovered_count++)) || true
|
||||
((enrolled_count++)) || true
|
||||
((failed_count--)) || true
|
||||
else
|
||||
warn " ✗ Agent installation still failed (exit: $install_exit_code)"
|
||||
fi
|
||||
else
|
||||
warn " ✗ Failed to fix dpkg in $name"
|
||||
info " dpkg output: $dpkg_output"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
info "Recovery complete: $recovered_count container(s) recovered"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $failed_count -gt 0 ]]; then
|
||||
warn "Some containers failed to enroll. Check the logs above for details."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Auto-enrollment complete! ✓"
|
||||
exit 0
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
|
||||
PM_DB_CONN_MAX_ATTEMPTS=30
|
||||
PM_DB_CONN_WAIT_INTERVAL=2
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
@@ -29,3 +31,8 @@ JWT_SECRET=your-secure-random-secret-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
# TFA Configuration
|
||||
TFA_REMEMBER_ME_EXPIRES_IN=30d
|
||||
TFA_MAX_REMEMBER_SESSIONS=5
|
||||
TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.2.7",
|
||||
"version": "1.2.8",
|
||||
"description": "Backend API for Linux Patch Monitoring System",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "auto_enrollment_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"token_name" TEXT NOT NULL,
|
||||
"token_key" TEXT NOT NULL,
|
||||
"token_secret" TEXT NOT NULL,
|
||||
"created_by_user_id" TEXT,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"allowed_ip_ranges" TEXT[],
|
||||
"max_hosts_per_day" INTEGER NOT NULL DEFAULT 100,
|
||||
"hosts_created_today" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_reset_date" DATE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"default_host_group_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"last_used_at" TIMESTAMP(3),
|
||||
"expires_at" TIMESTAMP(3),
|
||||
"metadata" JSONB,
|
||||
|
||||
CONSTRAINT "auto_enrollment_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "auto_enrollment_tokens_token_key_key" ON "auto_enrollment_tokens"("token_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "auto_enrollment_tokens_token_key_idx" ON "auto_enrollment_tokens"("token_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "auto_enrollment_tokens_is_active_idx" ON "auto_enrollment_tokens"("is_active");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_default_host_group_id_fkey" FOREIGN KEY ("default_host_group_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Add machine_id column as nullable first
|
||||
ALTER TABLE "hosts" ADD COLUMN "machine_id" TEXT;
|
||||
|
||||
-- Generate machine_ids for existing hosts using their API ID as a fallback
|
||||
UPDATE "hosts" SET "machine_id" = 'migrated-' || "api_id" WHERE "machine_id" IS NULL;
|
||||
|
||||
-- Remove the unique constraint from friendly_name
|
||||
ALTER TABLE "hosts" DROP CONSTRAINT IF EXISTS "hosts_friendly_name_key";
|
||||
|
||||
-- Also drop the unique index if it exists (constraint and index can exist separately)
|
||||
DROP INDEX IF EXISTS "hosts_friendly_name_key";
|
||||
|
||||
-- Now make machine_id NOT NULL and add unique constraint
|
||||
ALTER TABLE "hosts" ALTER COLUMN "machine_id" SET NOT NULL;
|
||||
ALTER TABLE "hosts" ADD CONSTRAINT "hosts_machine_id_key" UNIQUE ("machine_id");
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX "hosts_machine_id_idx" ON "hosts"("machine_id");
|
||||
CREATE INDEX "hosts_friendly_name_idx" ON "hosts"("friendly_name");
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AddLogoFieldsToSettings
|
||||
ALTER TABLE "settings" ADD COLUMN "logo_dark" VARCHAR(255) DEFAULT '/assets/logo_dark.png';
|
||||
ALTER TABLE "settings" ADD COLUMN "logo_light" VARCHAR(255) DEFAULT '/assets/logo_light.png';
|
||||
ALTER TABLE "settings" ADD COLUMN "favicon" VARCHAR(255) DEFAULT '/assets/logo_square.svg';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add TFA remember me fields to user_sessions table
|
||||
ALTER TABLE "user_sessions" ADD COLUMN "tfa_remember_me" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "user_sessions" ADD COLUMN "tfa_bypass_until" TIMESTAMP(3);
|
||||
|
||||
-- Create index for TFA bypass until field for efficient querying
|
||||
CREATE INDEX "user_sessions_tfa_bypass_until_idx" ON "user_sessions"("tfa_bypass_until");
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Add security fields to user_sessions table for production-ready remember me
|
||||
ALTER TABLE "user_sessions" ADD COLUMN "device_fingerprint" TEXT;
|
||||
ALTER TABLE "user_sessions" ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE "user_sessions" ADD COLUMN "last_login_ip" TEXT;
|
||||
|
||||
-- Create index for device fingerprint for efficient querying
|
||||
CREATE INDEX "user_sessions_device_fingerprint_idx" ON "user_sessions"("device_fingerprint");
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "update_history" ADD COLUMN "total_packages" INTEGER;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "update_history" ADD COLUMN "payload_size_kb" DOUBLE PRECISION;
|
||||
ALTER TABLE "update_history" ADD COLUMN "execution_time" DOUBLE PRECISION;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Add indexes to host_packages table for performance optimization
|
||||
-- These indexes will dramatically speed up queries filtering by host_id, package_id, needs_update, and is_security_update
|
||||
|
||||
-- Index for queries filtering by host_id (very common - used when viewing packages for a specific host)
|
||||
CREATE INDEX IF NOT EXISTS "host_packages_host_id_idx" ON "host_packages"("host_id");
|
||||
|
||||
-- Index for queries filtering by package_id (used when finding hosts for a specific package)
|
||||
CREATE INDEX IF NOT EXISTS "host_packages_package_id_idx" ON "host_packages"("package_id");
|
||||
|
||||
-- Index for queries filtering by needs_update (used when finding outdated packages)
|
||||
CREATE INDEX IF NOT EXISTS "host_packages_needs_update_idx" ON "host_packages"("needs_update");
|
||||
|
||||
-- Index for queries filtering by is_security_update (used when finding security updates)
|
||||
CREATE INDEX IF NOT EXISTS "host_packages_is_security_update_idx" ON "host_packages"("is_security_update");
|
||||
|
||||
-- Composite index for the most common query pattern: host_id + needs_update
|
||||
-- This is optimal for "show me outdated packages for this host"
|
||||
CREATE INDEX IF NOT EXISTS "host_packages_host_id_needs_update_idx" ON "host_packages"("host_id", "needs_update");
|
||||
|
||||
-- Composite index for host_id + needs_update + is_security_update
|
||||
-- This is optimal for "show me security updates for this host"
|
||||
CREATE INDEX IF NOT EXISTS "host_packages_host_id_needs_update_security_idx" ON "host_packages"("host_id", "needs_update", "is_security_update");
|
||||
|
||||
-- Index for queries filtering by package_id + needs_update
|
||||
-- This is optimal for "show me hosts where this package needs updates"
|
||||
CREATE INDEX IF NOT EXISTS "host_packages_package_id_needs_update_idx" ON "host_packages"("package_id", "needs_update");
|
||||
|
||||
-- Index on last_checked for cleanup/maintenance queries
|
||||
CREATE INDEX IF NOT EXISTS "host_packages_last_checked_idx" ON "host_packages"("last_checked");
|
||||
|
||||
@@ -21,13 +21,14 @@ model dashboard_preferences {
|
||||
}
|
||||
|
||||
model host_groups {
|
||||
id String @id
|
||||
name String @unique
|
||||
description String?
|
||||
color String? @default("#3B82F6")
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
hosts hosts[]
|
||||
id String @id
|
||||
name String @unique
|
||||
description String?
|
||||
color String? @default("#3B82F6")
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
hosts hosts[]
|
||||
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||
}
|
||||
|
||||
model host_packages {
|
||||
@@ -43,6 +44,14 @@ model host_packages {
|
||||
packages packages @relation(fields: [package_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([host_id, package_id])
|
||||
@@index([host_id])
|
||||
@@index([package_id])
|
||||
@@index([needs_update])
|
||||
@@index([is_security_update])
|
||||
@@index([host_id, needs_update])
|
||||
@@index([host_id, needs_update, is_security_update])
|
||||
@@index([package_id, needs_update])
|
||||
@@index([last_checked])
|
||||
}
|
||||
|
||||
model host_repositories {
|
||||
@@ -59,7 +68,8 @@ model host_repositories {
|
||||
|
||||
model hosts {
|
||||
id String @id
|
||||
friendly_name String @unique
|
||||
machine_id String @unique
|
||||
friendly_name String
|
||||
ip String?
|
||||
os_type String
|
||||
os_version String
|
||||
@@ -91,6 +101,10 @@ model hosts {
|
||||
host_repositories host_repositories[]
|
||||
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
|
||||
update_history update_history[]
|
||||
|
||||
@@index([machine_id])
|
||||
@@index([friendly_name])
|
||||
@@index([hostname])
|
||||
}
|
||||
|
||||
model packages {
|
||||
@@ -102,6 +116,9 @@ model packages {
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
host_packages host_packages[]
|
||||
|
||||
@@index([name])
|
||||
@@index([category])
|
||||
}
|
||||
|
||||
model repositories {
|
||||
@@ -158,36 +175,43 @@ model settings {
|
||||
signup_enabled Boolean @default(false)
|
||||
default_user_role String @default("user")
|
||||
ignore_ssl_self_signed Boolean @default(false)
|
||||
logo_dark String? @default("/assets/logo_dark.png")
|
||||
logo_light String? @default("/assets/logo_light.png")
|
||||
favicon String? @default("/assets/logo_square.svg")
|
||||
}
|
||||
|
||||
model update_history {
|
||||
id String @id
|
||||
host_id String
|
||||
packages_count Int
|
||||
security_count Int
|
||||
timestamp DateTime @default(now())
|
||||
status String @default("success")
|
||||
error_message String?
|
||||
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
|
||||
id String @id
|
||||
host_id String
|
||||
packages_count Int
|
||||
security_count Int
|
||||
total_packages Int?
|
||||
payload_size_kb Float?
|
||||
execution_time Float?
|
||||
timestamp DateTime @default(now())
|
||||
status String @default("success")
|
||||
error_message String?
|
||||
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model users {
|
||||
id String @id
|
||||
username String @unique
|
||||
email String @unique
|
||||
password_hash String
|
||||
role String @default("admin")
|
||||
is_active Boolean @default(true)
|
||||
last_login DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
tfa_backup_codes String?
|
||||
tfa_enabled Boolean @default(false)
|
||||
tfa_secret String?
|
||||
first_name String?
|
||||
last_name String?
|
||||
dashboard_preferences dashboard_preferences[]
|
||||
user_sessions user_sessions[]
|
||||
id String @id
|
||||
username String @unique
|
||||
email String @unique
|
||||
password_hash String
|
||||
role String @default("admin")
|
||||
is_active Boolean @default(true)
|
||||
last_login DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
tfa_backup_codes String?
|
||||
tfa_enabled Boolean @default(false)
|
||||
tfa_secret String?
|
||||
first_name String?
|
||||
last_name String?
|
||||
dashboard_preferences dashboard_preferences[]
|
||||
user_sessions user_sessions[]
|
||||
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||
}
|
||||
|
||||
model user_sessions {
|
||||
@@ -197,13 +221,44 @@ model user_sessions {
|
||||
access_token_hash String?
|
||||
ip_address String?
|
||||
user_agent String?
|
||||
device_fingerprint String?
|
||||
last_activity DateTime @default(now())
|
||||
expires_at DateTime
|
||||
created_at DateTime @default(now())
|
||||
is_revoked Boolean @default(false)
|
||||
tfa_remember_me Boolean @default(false)
|
||||
tfa_bypass_until DateTime?
|
||||
login_count Int @default(1)
|
||||
last_login_ip String?
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([user_id])
|
||||
@@index([refresh_token])
|
||||
@@index([expires_at])
|
||||
@@index([tfa_bypass_until])
|
||||
@@index([device_fingerprint])
|
||||
}
|
||||
|
||||
model auto_enrollment_tokens {
|
||||
id String @id
|
||||
token_name String
|
||||
token_key String @unique
|
||||
token_secret String
|
||||
created_by_user_id String?
|
||||
is_active Boolean @default(true)
|
||||
allowed_ip_ranges String[]
|
||||
max_hosts_per_day Int @default(100)
|
||||
hosts_created_today Int @default(0)
|
||||
last_reset_date DateTime @default(now()) @db.Date
|
||||
default_host_group_id String?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
last_used_at DateTime?
|
||||
expires_at DateTime?
|
||||
metadata Json?
|
||||
users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull)
|
||||
host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([token_key])
|
||||
@@index([is_active])
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const { PrismaClient } = require("@prisma/client");
|
||||
const {
|
||||
validate_session,
|
||||
update_session_activity,
|
||||
is_tfa_bypassed,
|
||||
} = require("../utils/session_manager");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@@ -18,10 +19,10 @@ const authenticateToken = async (req, res, next) => {
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(
|
||||
token,
|
||||
process.env.JWT_SECRET || "your-secret-key",
|
||||
);
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Validate session and check inactivity timeout
|
||||
const validation = await validate_session(decoded.sessionId, token);
|
||||
@@ -46,6 +47,9 @@ const authenticateToken = async (req, res, next) => {
|
||||
// Update session activity timestamp
|
||||
await update_session_activity(decoded.sessionId);
|
||||
|
||||
// Check if TFA is bypassed for this session
|
||||
const tfa_bypassed = await is_tfa_bypassed(decoded.sessionId);
|
||||
|
||||
// Update last login (only on successful authentication)
|
||||
await prisma.users.update({
|
||||
where: { id: validation.user.id },
|
||||
@@ -57,6 +61,7 @@ const authenticateToken = async (req, res, next) => {
|
||||
|
||||
req.user = validation.user;
|
||||
req.session_id = decoded.sessionId;
|
||||
req.tfa_bypassed = tfa_bypassed;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === "JsonWebTokenError") {
|
||||
@@ -85,10 +90,10 @@ const optionalAuth = async (req, _res, next) => {
|
||||
const token = authHeader?.split(" ")[1];
|
||||
|
||||
if (token) {
|
||||
const decoded = jwt.verify(
|
||||
token,
|
||||
process.env.JWT_SECRET || "your-secret-key",
|
||||
);
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
@@ -114,8 +119,33 @@ const optionalAuth = async (req, _res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to check if TFA is required for sensitive operations
|
||||
const requireTfaIfEnabled = async (req, res, next) => {
|
||||
try {
|
||||
// Check if user has TFA enabled
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: { tfa_enabled: true },
|
||||
});
|
||||
|
||||
// If TFA is enabled and not bypassed, require TFA verification
|
||||
if (user?.tfa_enabled && !req.tfa_bypassed) {
|
||||
return res.status(403).json({
|
||||
error: "Two-factor authentication required for this operation",
|
||||
requires_tfa: true,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("TFA requirement check error:", error);
|
||||
return res.status(500).json({ error: "Authentication check failed" });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
optionalAuth,
|
||||
requireTfaIfEnabled,
|
||||
};
|
||||
|
||||
@@ -17,12 +17,65 @@ const {
|
||||
refresh_access_token,
|
||||
revoke_session,
|
||||
revoke_all_user_sessions,
|
||||
get_user_sessions,
|
||||
} = require("../utils/session_manager");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Parse user agent string to extract browser and OS info
|
||||
*/
|
||||
function parse_user_agent(user_agent) {
|
||||
if (!user_agent)
|
||||
return { browser: "Unknown", os: "Unknown", device: "Unknown" };
|
||||
|
||||
const ua = user_agent.toLowerCase();
|
||||
|
||||
// Browser detection
|
||||
let browser = "Unknown";
|
||||
if (ua.includes("chrome") && !ua.includes("edg")) browser = "Chrome";
|
||||
else if (ua.includes("firefox")) browser = "Firefox";
|
||||
else if (ua.includes("safari") && !ua.includes("chrome")) browser = "Safari";
|
||||
else if (ua.includes("edg")) browser = "Edge";
|
||||
else if (ua.includes("opera")) browser = "Opera";
|
||||
|
||||
// OS detection
|
||||
let os = "Unknown";
|
||||
if (ua.includes("windows")) os = "Windows";
|
||||
else if (ua.includes("macintosh") || ua.includes("mac os")) os = "macOS";
|
||||
else if (ua.includes("linux")) os = "Linux";
|
||||
else if (ua.includes("android")) os = "Android";
|
||||
else if (ua.includes("iphone") || ua.includes("ipad")) os = "iOS";
|
||||
|
||||
// Device type
|
||||
let device = "Desktop";
|
||||
if (ua.includes("mobile")) device = "Mobile";
|
||||
else if (ua.includes("tablet") || ua.includes("ipad")) device = "Tablet";
|
||||
|
||||
return { browser, os, device };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get basic location info from IP (simplified - in production you'd use a service)
|
||||
*/
|
||||
function get_location_from_ip(ip) {
|
||||
if (!ip) return { country: "Unknown", city: "Unknown" };
|
||||
|
||||
// For localhost/private IPs
|
||||
if (
|
||||
ip === "127.0.0.1" ||
|
||||
ip === "::1" ||
|
||||
ip.startsWith("192.168.") ||
|
||||
ip.startsWith("10.")
|
||||
) {
|
||||
return { country: "Local", city: "Local Network" };
|
||||
}
|
||||
|
||||
// In a real implementation, you'd use a service like MaxMind GeoIP2
|
||||
// For now, return unknown for external IPs
|
||||
return { country: "Unknown", city: "Unknown" };
|
||||
}
|
||||
|
||||
// Check if any admin users exist (for first-time setup)
|
||||
router.get("/check-admin-users", async (_req, res) => {
|
||||
try {
|
||||
@@ -156,7 +209,10 @@ router.post(
|
||||
|
||||
// Generate JWT token
|
||||
const generateToken = (userId) => {
|
||||
return jwt.sign({ userId }, process.env.JWT_SECRET || "your-secret-key", {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
return jwt.sign({ userId }, process.env.JWT_SECRET, {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || "24h",
|
||||
});
|
||||
};
|
||||
@@ -173,6 +229,8 @@ router.get(
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
role: true,
|
||||
is_active: true,
|
||||
last_login: true,
|
||||
@@ -311,6 +369,14 @@ router.put(
|
||||
.isLength({ min: 3 })
|
||||
.withMessage("Username must be at least 3 characters"),
|
||||
body("email").optional().isEmail().withMessage("Valid email is required"),
|
||||
body("first_name")
|
||||
.optional()
|
||||
.isLength({ min: 1 })
|
||||
.withMessage("First name must be at least 1 character"),
|
||||
body("last_name")
|
||||
.optional()
|
||||
.isLength({ min: 1 })
|
||||
.withMessage("Last name must be at least 1 character"),
|
||||
body("role")
|
||||
.optional()
|
||||
.custom(async (value) => {
|
||||
@@ -323,10 +389,10 @@ router.put(
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body("isActive")
|
||||
body("is_active")
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage("isActive must be a boolean"),
|
||||
.withMessage("is_active must be a boolean"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -337,13 +403,16 @@ router.put(
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, email, role, isActive } = req.body;
|
||||
const { username, email, first_name, last_name, role, is_active } =
|
||||
req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (username) updateData.username = username;
|
||||
if (email) updateData.email = email;
|
||||
if (first_name !== undefined) updateData.first_name = first_name || null;
|
||||
if (last_name !== undefined) updateData.last_name = last_name || null;
|
||||
if (role) updateData.role = role;
|
||||
if (typeof isActive === "boolean") updateData.is_active = isActive;
|
||||
if (typeof is_active === "boolean") updateData.is_active = is_active;
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await prisma.users.findUnique({
|
||||
@@ -378,7 +447,7 @@ router.put(
|
||||
}
|
||||
|
||||
// Prevent deactivating the last admin
|
||||
if (isActive === false && existingUser.role === "admin") {
|
||||
if (is_active === false && existingUser.role === "admin") {
|
||||
const adminCount = await prisma.users.count({
|
||||
where: {
|
||||
role: "admin",
|
||||
@@ -401,6 +470,8 @@ router.put(
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
role: true,
|
||||
is_active: true,
|
||||
last_login: true,
|
||||
@@ -747,6 +818,8 @@ router.post(
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
last_login: user.last_login,
|
||||
@@ -770,6 +843,10 @@ router.post(
|
||||
.isLength({ min: 6, max: 6 })
|
||||
.withMessage("Token must be 6 digits"),
|
||||
body("token").isNumeric().withMessage("Token must contain only numbers"),
|
||||
body("remember_me")
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage("Remember me must be a boolean"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -778,7 +855,7 @@ router.post(
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, token } = req.body;
|
||||
const { username, token, remember_me = false } = req.body;
|
||||
|
||||
// Find user
|
||||
const user = await prisma.users.findFirst({
|
||||
@@ -847,13 +924,20 @@ router.post(
|
||||
// Create session with access and refresh tokens
|
||||
const ip_address = req.ip || req.connection.remoteAddress;
|
||||
const user_agent = req.get("user-agent");
|
||||
const session = await create_session(user.id, ip_address, user_agent);
|
||||
const session = await create_session(
|
||||
user.id,
|
||||
ip_address,
|
||||
user_agent,
|
||||
remember_me,
|
||||
req,
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Login successful",
|
||||
token: session.access_token,
|
||||
refresh_token: session.refresh_token,
|
||||
expires_at: session.expires_at,
|
||||
tfa_bypass_until: session.tfa_bypass_until,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -1091,10 +1175,43 @@ router.post(
|
||||
// Get user's active sessions
|
||||
router.get("/sessions", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const sessions = await get_user_sessions(req.user.id);
|
||||
const sessions = await prisma.user_sessions.findMany({
|
||||
where: {
|
||||
user_id: req.user.id,
|
||||
is_revoked: false,
|
||||
expires_at: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
ip_address: true,
|
||||
user_agent: true,
|
||||
device_fingerprint: true,
|
||||
last_activity: true,
|
||||
created_at: true,
|
||||
expires_at: true,
|
||||
tfa_remember_me: true,
|
||||
tfa_bypass_until: true,
|
||||
login_count: true,
|
||||
last_login_ip: true,
|
||||
},
|
||||
orderBy: { last_activity: "desc" },
|
||||
});
|
||||
|
||||
// Enhance sessions with device info
|
||||
const enhanced_sessions = sessions.map((session) => {
|
||||
const is_current_session = session.id === req.session_id;
|
||||
const device_info = parse_user_agent(session.user_agent);
|
||||
|
||||
return {
|
||||
...session,
|
||||
is_current_session,
|
||||
device_info,
|
||||
location_info: get_location_from_ip(session.ip_address),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
sessions: sessions,
|
||||
sessions: enhanced_sessions,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Get sessions error:", error);
|
||||
@@ -1116,6 +1233,11 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
|
||||
// Don't allow revoking the current session
|
||||
if (session_id === req.session_id) {
|
||||
return res.status(400).json({ error: "Cannot revoke current session" });
|
||||
}
|
||||
|
||||
await revoke_session(session_id);
|
||||
|
||||
res.json({
|
||||
@@ -1127,4 +1249,25 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Revoke all sessions except current one
|
||||
router.delete("/sessions", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// Revoke all sessions except the current one
|
||||
await prisma.user_sessions.updateMany({
|
||||
where: {
|
||||
user_id: req.user.id,
|
||||
id: { not: req.session_id },
|
||||
},
|
||||
data: { is_revoked: true },
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "All other sessions revoked successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Revoke all sessions error:", error);
|
||||
res.status(500).json({ error: "Failed to revoke sessions" });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
745
backend/src/routes/autoEnrollmentRoutes.js
Normal file
745
backend/src/routes/autoEnrollmentRoutes.js
Normal file
@@ -0,0 +1,745 @@
|
||||
const express = require("express");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const crypto = require("node:crypto");
|
||||
const bcrypt = require("bcryptjs");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requireManageSettings } = require("../middleware/permissions");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Generate auto-enrollment token credentials
|
||||
const generate_auto_enrollment_token = () => {
|
||||
const token_key = `patchmon_ae_${crypto.randomBytes(16).toString("hex")}`;
|
||||
const token_secret = crypto.randomBytes(48).toString("hex");
|
||||
return { token_key, token_secret };
|
||||
};
|
||||
|
||||
// Middleware to validate auto-enrollment token
|
||||
const validate_auto_enrollment_token = async (req, res, next) => {
|
||||
try {
|
||||
const token_key = req.headers["x-auto-enrollment-key"];
|
||||
const token_secret = req.headers["x-auto-enrollment-secret"];
|
||||
|
||||
if (!token_key || !token_secret) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Auto-enrollment credentials required" });
|
||||
}
|
||||
|
||||
// Find token
|
||||
const token = await prisma.auto_enrollment_tokens.findUnique({
|
||||
where: { token_key: token_key },
|
||||
});
|
||||
|
||||
if (!token || !token.is_active) {
|
||||
return res.status(401).json({ error: "Invalid or inactive token" });
|
||||
}
|
||||
|
||||
// Verify secret (hashed)
|
||||
const is_valid = await bcrypt.compare(token_secret, token.token_secret);
|
||||
if (!is_valid) {
|
||||
return res.status(401).json({ error: "Invalid token secret" });
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (token.expires_at && new Date() > new Date(token.expires_at)) {
|
||||
return res.status(401).json({ error: "Token expired" });
|
||||
}
|
||||
|
||||
// Check IP whitelist if configured
|
||||
if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) {
|
||||
const client_ip = req.ip || req.connection.remoteAddress;
|
||||
// Basic IP check - can be enhanced with CIDR matching
|
||||
const ip_allowed = token.allowed_ip_ranges.some((allowed_ip) => {
|
||||
return client_ip.includes(allowed_ip);
|
||||
});
|
||||
|
||||
if (!ip_allowed) {
|
||||
console.warn(
|
||||
`Auto-enrollment attempt from unauthorized IP: ${client_ip}`,
|
||||
);
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "IP address not authorized for this token" });
|
||||
}
|
||||
}
|
||||
|
||||
// Check rate limit (hosts per day)
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const token_reset_date = token.last_reset_date.toISOString().split("T")[0];
|
||||
|
||||
if (token_reset_date !== today) {
|
||||
// Reset daily counter
|
||||
await prisma.auto_enrollment_tokens.update({
|
||||
where: { id: token.id },
|
||||
data: {
|
||||
hosts_created_today: 0,
|
||||
last_reset_date: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
token.hosts_created_today = 0;
|
||||
}
|
||||
|
||||
if (token.hosts_created_today >= token.max_hosts_per_day) {
|
||||
return res.status(429).json({
|
||||
error: "Rate limit exceeded",
|
||||
message: `Maximum ${token.max_hosts_per_day} hosts per day allowed for this token`,
|
||||
});
|
||||
}
|
||||
|
||||
req.auto_enrollment_token = token;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("Auto-enrollment token validation error:", error);
|
||||
res.status(500).json({ error: "Token validation failed" });
|
||||
}
|
||||
};
|
||||
|
||||
// ========== ADMIN ENDPOINTS (Manage Tokens) ==========
|
||||
|
||||
// Create auto-enrollment token
|
||||
router.post(
|
||||
"/tokens",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
[
|
||||
body("token_name")
|
||||
.isLength({ min: 1, max: 255 })
|
||||
.withMessage("Token name is required (max 255 characters)"),
|
||||
body("allowed_ip_ranges")
|
||||
.optional()
|
||||
.isArray()
|
||||
.withMessage("Allowed IP ranges must be an array"),
|
||||
body("max_hosts_per_day")
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 1000 })
|
||||
.withMessage("Max hosts per day must be between 1 and 1000"),
|
||||
body("default_host_group_id")
|
||||
.optional({ nullable: true, checkFalsy: true })
|
||||
.isString(),
|
||||
body("expires_at")
|
||||
.optional({ nullable: true, checkFalsy: true })
|
||||
.isISO8601()
|
||||
.withMessage("Invalid date format"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
token_name,
|
||||
allowed_ip_ranges = [],
|
||||
max_hosts_per_day = 100,
|
||||
default_host_group_id,
|
||||
expires_at,
|
||||
metadata = {},
|
||||
} = req.body;
|
||||
|
||||
// Validate host group if provided
|
||||
if (default_host_group_id) {
|
||||
const host_group = await prisma.host_groups.findUnique({
|
||||
where: { id: default_host_group_id },
|
||||
});
|
||||
|
||||
if (!host_group) {
|
||||
return res.status(400).json({ error: "Host group not found" });
|
||||
}
|
||||
}
|
||||
|
||||
const { token_key, token_secret } = generate_auto_enrollment_token();
|
||||
const hashed_secret = await bcrypt.hash(token_secret, 10);
|
||||
|
||||
const token = await prisma.auto_enrollment_tokens.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
token_name,
|
||||
token_key: token_key,
|
||||
token_secret: hashed_secret,
|
||||
created_by_user_id: req.user.id,
|
||||
allowed_ip_ranges,
|
||||
max_hosts_per_day,
|
||||
default_host_group_id: default_host_group_id || null,
|
||||
expires_at: expires_at ? new Date(expires_at) : null,
|
||||
metadata: { integration_type: "proxmox-lxc", ...metadata },
|
||||
updated_at: new Date(),
|
||||
},
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Return unhashed secret ONLY once (like API keys)
|
||||
res.status(201).json({
|
||||
message: "Auto-enrollment token created successfully",
|
||||
token: {
|
||||
id: token.id,
|
||||
token_name: token.token_name,
|
||||
token_key: token_key,
|
||||
token_secret: token_secret, // ONLY returned here!
|
||||
max_hosts_per_day: token.max_hosts_per_day,
|
||||
default_host_group: token.host_groups,
|
||||
created_by: token.users,
|
||||
expires_at: token.expires_at,
|
||||
},
|
||||
warning: "⚠️ Save the token_secret now - it cannot be retrieved later!",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Create auto-enrollment token error:", error);
|
||||
res.status(500).json({ error: "Failed to create token" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// List auto-enrollment tokens
|
||||
router.get(
|
||||
"/tokens",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const tokens = await prisma.auto_enrollment_tokens.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
token_name: true,
|
||||
token_key: true,
|
||||
is_active: true,
|
||||
allowed_ip_ranges: true,
|
||||
max_hosts_per_day: true,
|
||||
hosts_created_today: true,
|
||||
last_used_at: true,
|
||||
expires_at: true,
|
||||
created_at: true,
|
||||
default_host_group_id: true,
|
||||
metadata: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
|
||||
res.json(tokens);
|
||||
} catch (error) {
|
||||
console.error("List auto-enrollment tokens error:", error);
|
||||
res.status(500).json({ error: "Failed to list tokens" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get single token details
|
||||
router.get(
|
||||
"/tokens/:tokenId",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { tokenId } = req.params;
|
||||
|
||||
const token = await prisma.auto_enrollment_tokens.findUnique({
|
||||
where: { id: tokenId },
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return res.status(404).json({ error: "Token not found" });
|
||||
}
|
||||
|
||||
// Don't include the secret in response
|
||||
const { token_secret: _secret, ...token_data } = token;
|
||||
|
||||
res.json(token_data);
|
||||
} catch (error) {
|
||||
console.error("Get token error:", error);
|
||||
res.status(500).json({ error: "Failed to get token" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update token (toggle active state, update limits, etc.)
|
||||
router.patch(
|
||||
"/tokens/:tokenId",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
[
|
||||
body("is_active").optional().isBoolean(),
|
||||
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
|
||||
body("allowed_ip_ranges").optional().isArray(),
|
||||
body("expires_at").optional().isISO8601(),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { tokenId } = req.params;
|
||||
const update_data = { updated_at: new Date() };
|
||||
|
||||
if (req.body.is_active !== undefined)
|
||||
update_data.is_active = req.body.is_active;
|
||||
if (req.body.max_hosts_per_day !== undefined)
|
||||
update_data.max_hosts_per_day = req.body.max_hosts_per_day;
|
||||
if (req.body.allowed_ip_ranges !== undefined)
|
||||
update_data.allowed_ip_ranges = req.body.allowed_ip_ranges;
|
||||
if (req.body.expires_at !== undefined)
|
||||
update_data.expires_at = new Date(req.body.expires_at);
|
||||
|
||||
const token = await prisma.auto_enrollment_tokens.update({
|
||||
where: { id: tokenId },
|
||||
data: update_data,
|
||||
include: {
|
||||
host_groups: true,
|
||||
users: {
|
||||
select: {
|
||||
username: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { token_secret: _secret, ...token_data } = token;
|
||||
|
||||
res.json({
|
||||
message: "Token updated successfully",
|
||||
token: token_data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Update token error:", error);
|
||||
res.status(500).json({ error: "Failed to update token" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete token
|
||||
router.delete(
|
||||
"/tokens/:tokenId",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { tokenId } = req.params;
|
||||
|
||||
const token = await prisma.auto_enrollment_tokens.findUnique({
|
||||
where: { id: tokenId },
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return res.status(404).json({ error: "Token not found" });
|
||||
}
|
||||
|
||||
await prisma.auto_enrollment_tokens.delete({
|
||||
where: { id: tokenId },
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "Auto-enrollment token deleted successfully",
|
||||
deleted_token: {
|
||||
id: token.id,
|
||||
token_name: token.token_name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Delete token error:", error);
|
||||
res.status(500).json({ error: "Failed to delete token" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ========== AUTO-ENROLLMENT ENDPOINTS (Used by Scripts) ==========
|
||||
// Future integrations can follow this pattern:
|
||||
// - /proxmox-lxc - Proxmox LXC containers
|
||||
// - /vmware-esxi - VMware ESXi VMs
|
||||
// - /docker - Docker containers
|
||||
// - /kubernetes - Kubernetes pods
|
||||
// - /aws-ec2 - AWS EC2 instances
|
||||
|
||||
// Serve the Proxmox LXC enrollment script with credentials injected
|
||||
router.get("/proxmox-lxc", async (req, res) => {
|
||||
try {
|
||||
// Get token from query params
|
||||
const token_key = req.query.token_key;
|
||||
const token_secret = req.query.token_secret;
|
||||
|
||||
if (!token_key || !token_secret) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Token key and secret required as query parameters" });
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const token = await prisma.auto_enrollment_tokens.findUnique({
|
||||
where: { token_key: token_key },
|
||||
});
|
||||
|
||||
if (!token || !token.is_active) {
|
||||
return res.status(401).json({ error: "Invalid or inactive token" });
|
||||
}
|
||||
|
||||
// Verify secret
|
||||
const is_valid = await bcrypt.compare(token_secret, token.token_secret);
|
||||
if (!is_valid) {
|
||||
return res.status(401).json({ error: "Invalid token secret" });
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (token.expires_at && new Date() > new Date(token.expires_at)) {
|
||||
return res.status(401).json({ error: "Token expired" });
|
||||
}
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const script_path = path.join(
|
||||
__dirname,
|
||||
"../../../agents/proxmox_auto_enroll.sh",
|
||||
);
|
||||
|
||||
if (!fs.existsSync(script_path)) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Proxmox enrollment script not found" });
|
||||
}
|
||||
|
||||
let script = fs.readFileSync(script_path, "utf8");
|
||||
|
||||
// Convert Windows line endings to Unix line endings
|
||||
script = script.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
// Get the configured server URL from settings
|
||||
let server_url = "http://localhost:3001";
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings?.server_url) {
|
||||
server_url = settings.server_url;
|
||||
}
|
||||
} catch (settings_error) {
|
||||
console.warn(
|
||||
"Could not fetch settings, using default server URL:",
|
||||
settings_error.message,
|
||||
);
|
||||
}
|
||||
|
||||
// Determine curl flags dynamically from settings
|
||||
let curl_flags = "-s";
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings && settings.ignore_ssl_self_signed === true) {
|
||||
curl_flags = "-sk";
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Check for --force parameter
|
||||
const force_install = req.query.force === "true" || req.query.force === "1";
|
||||
|
||||
// Inject the token credentials, server URL, curl flags, and force flag into the script
|
||||
const env_vars = `#!/bin/bash
|
||||
# PatchMon Auto-Enrollment Configuration (Auto-generated)
|
||||
export PATCHMON_URL="${server_url}"
|
||||
export AUTO_ENROLLMENT_KEY="${token.token_key}"
|
||||
export AUTO_ENROLLMENT_SECRET="${token_secret}"
|
||||
export CURL_FLAGS="${curl_flags}"
|
||||
export FORCE_INSTALL="${force_install ? "true" : "false"}"
|
||||
|
||||
`;
|
||||
|
||||
// Remove the shebang and configuration section from the original script
|
||||
script = script.replace(/^#!/, "#");
|
||||
|
||||
// Remove the configuration section (between # ===== CONFIGURATION ===== and the next # =====)
|
||||
script = script.replace(
|
||||
/# ===== CONFIGURATION =====[\s\S]*?(?=# ===== COLOR OUTPUT =====)/,
|
||||
"",
|
||||
);
|
||||
|
||||
script = env_vars + script;
|
||||
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
'inline; filename="proxmox_auto_enroll.sh"',
|
||||
);
|
||||
res.send(script);
|
||||
} catch (error) {
|
||||
console.error("Proxmox script serve error:", error);
|
||||
res.status(500).json({ error: "Failed to serve enrollment script" });
|
||||
}
|
||||
});
|
||||
|
||||
// Create host via auto-enrollment
|
||||
router.post(
|
||||
"/enroll",
|
||||
validate_auto_enrollment_token,
|
||||
[
|
||||
body("friendly_name")
|
||||
.isLength({ min: 1, max: 255 })
|
||||
.withMessage("Friendly name is required"),
|
||||
body("machine_id")
|
||||
.isLength({ min: 1, max: 255 })
|
||||
.withMessage("Machine ID is required"),
|
||||
body("metadata").optional().isObject(),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { friendly_name, machine_id } = req.body;
|
||||
|
||||
// Generate host API credentials
|
||||
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
|
||||
const api_key = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
// Check if host already exists by machine_id (not hostname)
|
||||
const existing_host = await prisma.hosts.findUnique({
|
||||
where: { machine_id },
|
||||
});
|
||||
|
||||
if (existing_host) {
|
||||
return res.status(409).json({
|
||||
error: "Host already exists",
|
||||
host_id: existing_host.id,
|
||||
api_id: existing_host.api_id,
|
||||
machine_id: existing_host.machine_id,
|
||||
friendly_name: existing_host.friendly_name,
|
||||
message:
|
||||
"This machine is already enrolled in PatchMon (matched by machine ID)",
|
||||
});
|
||||
}
|
||||
|
||||
// Create host
|
||||
const host = await prisma.hosts.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
machine_id,
|
||||
friendly_name,
|
||||
os_type: "unknown",
|
||||
os_version: "unknown",
|
||||
api_id: api_id,
|
||||
api_key: api_key,
|
||||
host_group_id: req.auto_enrollment_token.default_host_group_id,
|
||||
status: "pending",
|
||||
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update token usage stats
|
||||
await prisma.auto_enrollment_tokens.update({
|
||||
where: { id: req.auto_enrollment_token.id },
|
||||
data: {
|
||||
hosts_created_today: { increment: 1 },
|
||||
last_used_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Auto-enrolled host: ${friendly_name} (${host.id}) via token: ${req.auto_enrollment_token.token_name}`,
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: "Host enrolled successfully",
|
||||
host: {
|
||||
id: host.id,
|
||||
friendly_name: host.friendly_name,
|
||||
api_id: api_id,
|
||||
api_key: api_key,
|
||||
host_group: host.host_groups,
|
||||
status: host.status,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Auto-enrollment error:", error);
|
||||
res.status(500).json({ error: "Failed to enroll host" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Bulk enroll multiple hosts at once
|
||||
router.post(
|
||||
"/enroll/bulk",
|
||||
validate_auto_enrollment_token,
|
||||
[
|
||||
body("hosts")
|
||||
.isArray({ min: 1, max: 50 })
|
||||
.withMessage("Hosts array required (max 50)"),
|
||||
body("hosts.*.friendly_name")
|
||||
.isLength({ min: 1 })
|
||||
.withMessage("Each host needs a friendly_name"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hosts } = req.body;
|
||||
|
||||
// Check rate limit
|
||||
const remaining_quota =
|
||||
req.auto_enrollment_token.max_hosts_per_day -
|
||||
req.auto_enrollment_token.hosts_created_today;
|
||||
|
||||
if (hosts.length > remaining_quota) {
|
||||
return res.status(429).json({
|
||||
error: "Rate limit exceeded",
|
||||
message: `Only ${remaining_quota} hosts remaining in daily quota`,
|
||||
});
|
||||
}
|
||||
|
||||
const results = {
|
||||
success: [],
|
||||
failed: [],
|
||||
skipped: [],
|
||||
};
|
||||
|
||||
for (const host_data of hosts) {
|
||||
try {
|
||||
const { friendly_name, machine_id } = host_data;
|
||||
|
||||
if (!machine_id) {
|
||||
results.failed.push({
|
||||
friendly_name,
|
||||
error: "Machine ID is required",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if host already exists by machine_id
|
||||
const existing_host = await prisma.hosts.findUnique({
|
||||
where: { machine_id },
|
||||
});
|
||||
|
||||
if (existing_host) {
|
||||
results.skipped.push({
|
||||
friendly_name,
|
||||
machine_id,
|
||||
reason: "Machine already enrolled",
|
||||
api_id: existing_host.api_id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate credentials
|
||||
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
|
||||
const api_key = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
// Create host
|
||||
const host = await prisma.hosts.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
machine_id,
|
||||
friendly_name,
|
||||
os_type: "unknown",
|
||||
os_version: "unknown",
|
||||
api_id: api_id,
|
||||
api_key: api_key,
|
||||
host_group_id: req.auto_enrollment_token.default_host_group_id,
|
||||
status: "pending",
|
||||
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
results.success.push({
|
||||
id: host.id,
|
||||
friendly_name: host.friendly_name,
|
||||
api_id: api_id,
|
||||
api_key: api_key,
|
||||
});
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
friendly_name: host_data.friendly_name,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update token usage stats
|
||||
if (results.success.length > 0) {
|
||||
await prisma.auto_enrollment_tokens.update({
|
||||
where: { id: req.auto_enrollment_token.id },
|
||||
data: {
|
||||
hosts_created_today: { increment: results.success.length },
|
||||
last_used_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
message: `Bulk enrollment completed: ${results.success.length} succeeded, ${results.failed.length} failed, ${results.skipped.length} skipped`,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Bulk auto-enrollment error:", error);
|
||||
res.status(500).json({ error: "Failed to bulk enroll hosts" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -130,15 +130,20 @@ async function createDefaultDashboardPreferences(userId, userRole = "user") {
|
||||
requiredPermission: "can_view_packages",
|
||||
order: 13,
|
||||
},
|
||||
{
|
||||
cardId: "packageTrends",
|
||||
requiredPermission: "can_view_packages",
|
||||
order: 14,
|
||||
},
|
||||
{
|
||||
cardId: "recentUsers",
|
||||
requiredPermission: "can_view_users",
|
||||
order: 14,
|
||||
order: 15,
|
||||
},
|
||||
{
|
||||
cardId: "quickStats",
|
||||
requiredPermission: "can_view_dashboard",
|
||||
order: 15,
|
||||
order: 16,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -341,19 +346,26 @@ router.get("/defaults", authenticateToken, async (_req, res) => {
|
||||
enabled: true,
|
||||
order: 13,
|
||||
},
|
||||
{
|
||||
cardId: "packageTrends",
|
||||
title: "Package Trends",
|
||||
icon: "TrendingUp",
|
||||
enabled: true,
|
||||
order: 14,
|
||||
},
|
||||
{
|
||||
cardId: "recentUsers",
|
||||
title: "Recent Users Logged in",
|
||||
icon: "Users",
|
||||
enabled: true,
|
||||
order: 14,
|
||||
order: 15,
|
||||
},
|
||||
{
|
||||
cardId: "quickStats",
|
||||
title: "Quick Stats",
|
||||
icon: "TrendingUp",
|
||||
enabled: true,
|
||||
order: 15,
|
||||
order: 16,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -145,9 +145,13 @@ router.get(
|
||||
];
|
||||
|
||||
// Package update priority distribution
|
||||
const regularUpdates = Math.max(
|
||||
0,
|
||||
totalOutdatedPackages - securityUpdates,
|
||||
);
|
||||
const packageUpdateDistribution = [
|
||||
{ name: "Security", count: securityUpdates },
|
||||
{ name: "Regular", count: totalOutdatedPackages - securityUpdates },
|
||||
{ name: "Regular", count: regularUpdates },
|
||||
];
|
||||
|
||||
res.json({
|
||||
@@ -185,6 +189,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
||||
// Show all hosts regardless of status
|
||||
select: {
|
||||
id: true,
|
||||
machine_id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
@@ -342,32 +347,41 @@ router.get(
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
const limit = parseInt(req.query.limit, 10) || 10;
|
||||
const offset = parseInt(req.query.offset, 10) || 0;
|
||||
|
||||
const [host, totalHistoryCount] = await Promise.all([
|
||||
prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
include: {
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
host_packages: {
|
||||
include: {
|
||||
packages: true,
|
||||
},
|
||||
orderBy: {
|
||||
needs_update: "desc",
|
||||
},
|
||||
},
|
||||
update_history: {
|
||||
orderBy: {
|
||||
timestamp: "desc",
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
},
|
||||
},
|
||||
host_packages: {
|
||||
include: {
|
||||
packages: true,
|
||||
},
|
||||
orderBy: {
|
||||
needs_update: "desc",
|
||||
},
|
||||
},
|
||||
update_history: {
|
||||
orderBy: {
|
||||
timestamp: "desc",
|
||||
},
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
prisma.update_history.count({
|
||||
where: { host_id: hostId },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
@@ -383,6 +397,12 @@ router.get(
|
||||
(hp) => hp.needs_update && hp.is_security_update,
|
||||
).length,
|
||||
},
|
||||
pagination: {
|
||||
total: totalHistoryCount,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < totalHistoryCount,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(hostWithStats);
|
||||
@@ -455,4 +475,132 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Get package trends over time
|
||||
router.get(
|
||||
"/package-trends",
|
||||
authenticateToken,
|
||||
requireViewHosts,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { days = 30, hostId } = req.query;
|
||||
const daysInt = parseInt(days, 10);
|
||||
|
||||
// Calculate date range
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(endDate.getDate() - daysInt);
|
||||
|
||||
// Build where clause
|
||||
const whereClause = {
|
||||
timestamp: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
};
|
||||
|
||||
// Add host filter if specified
|
||||
if (hostId && hostId !== "all" && hostId !== "undefined") {
|
||||
whereClause.host_id = hostId;
|
||||
}
|
||||
|
||||
// Get all update history records in the date range
|
||||
const trendsData = await prisma.update_history.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
timestamp: true,
|
||||
packages_count: true,
|
||||
security_count: true,
|
||||
total_packages: true,
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Process data to show actual values (no averaging)
|
||||
const processedData = trendsData
|
||||
.filter((record) => record.total_packages !== null) // Only include records with valid data
|
||||
.map((record) => {
|
||||
const date = new Date(record.timestamp);
|
||||
let timeKey;
|
||||
|
||||
if (daysInt <= 1) {
|
||||
// For hourly view, use exact timestamp
|
||||
timeKey = date.toISOString().substring(0, 16); // YYYY-MM-DDTHH:MM
|
||||
} else {
|
||||
// For daily view, group by day
|
||||
timeKey = date.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
return {
|
||||
timeKey,
|
||||
total_packages: record.total_packages,
|
||||
packages_count: record.packages_count || 0,
|
||||
security_count: record.security_count || 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.timeKey.localeCompare(b.timeKey)); // Sort by time
|
||||
|
||||
// Get hosts list for dropdown (always fetch for dropdown functionality)
|
||||
const hostsList = await prisma.hosts.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
},
|
||||
orderBy: {
|
||||
friendly_name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Format data for chart
|
||||
const chartData = {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: "Total Packages",
|
||||
data: [],
|
||||
borderColor: "#3B82F6", // Blue
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
tension: 0.4,
|
||||
hidden: true, // Hidden by default
|
||||
},
|
||||
{
|
||||
label: "Outdated Packages",
|
||||
data: [],
|
||||
borderColor: "#F59E0B", // Orange
|
||||
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
||||
tension: 0.4,
|
||||
},
|
||||
{
|
||||
label: "Security Packages",
|
||||
data: [],
|
||||
borderColor: "#EF4444", // Red
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Process aggregated data
|
||||
processedData.forEach((item) => {
|
||||
chartData.labels.push(item.timeKey);
|
||||
chartData.datasets[0].data.push(item.total_packages);
|
||||
chartData.datasets[1].data.push(item.packages_count);
|
||||
chartData.datasets[2].data.push(item.security_count);
|
||||
});
|
||||
|
||||
res.json({
|
||||
chartData,
|
||||
hosts: hostsList,
|
||||
period: daysInt,
|
||||
hostId: hostId || "all",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching package trends:", error);
|
||||
res.status(500).json({ error: "Failed to fetch package trends" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -172,15 +172,6 @@ router.post(
|
||||
// Generate unique API credentials for this host
|
||||
const { apiId, apiKey } = generateApiCredentials();
|
||||
|
||||
// Check if host already exists
|
||||
const existingHost = await prisma.hosts.findUnique({
|
||||
where: { friendly_name: friendly_name },
|
||||
});
|
||||
|
||||
if (existingHost) {
|
||||
return res.status(409).json({ error: "Host already exists" });
|
||||
}
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.host_groups.findUnique({
|
||||
@@ -196,6 +187,7 @@ router.post(
|
||||
const host = await prisma.hosts.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
machine_id: `pending-${uuidv4()}`, // Temporary placeholder until agent connects with real machine_id
|
||||
friendly_name: friendly_name,
|
||||
os_type: "unknown", // Will be updated when agent connects
|
||||
os_version: "unknown", // Will be updated when agent connects
|
||||
@@ -321,6 +313,10 @@ router.post(
|
||||
.optional()
|
||||
.isArray()
|
||||
.withMessage("Load average must be an array"),
|
||||
body("machineId")
|
||||
.optional()
|
||||
.isString()
|
||||
.withMessage("Machine ID must be a string"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -329,15 +325,24 @@ router.post(
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { packages, repositories } = req.body;
|
||||
const { packages, repositories, executionTime } = req.body;
|
||||
const host = req.hostRecord;
|
||||
|
||||
// Calculate payload size in KB
|
||||
const payloadSizeBytes = JSON.stringify(req.body).length;
|
||||
const payloadSizeKb = payloadSizeBytes / 1024;
|
||||
|
||||
// Update host last update timestamp and system info if provided
|
||||
const updateData = {
|
||||
last_update: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
// Update machine_id if provided and current one is a placeholder
|
||||
if (req.body.machineId && host.machine_id.startsWith("pending-")) {
|
||||
updateData.machine_id = req.body.machineId;
|
||||
}
|
||||
|
||||
// Basic system info
|
||||
if (req.body.osType) updateData.os_type = req.body.osType;
|
||||
if (req.body.osVersion) updateData.os_version = req.body.osVersion;
|
||||
@@ -382,6 +387,7 @@ router.post(
|
||||
(pkg) => pkg.isSecurityUpdate,
|
||||
).length;
|
||||
const updatesCount = packages.filter((pkg) => pkg.needsUpdate).length;
|
||||
const totalPackages = packages.length;
|
||||
|
||||
// Process everything in a single transaction to avoid race conditions
|
||||
await prisma.$transaction(async (tx) => {
|
||||
@@ -524,6 +530,9 @@ router.post(
|
||||
host_id: host.id,
|
||||
packages_count: updatesCount,
|
||||
security_count: securityCount,
|
||||
total_packages: totalPackages,
|
||||
payload_size_kb: payloadSizeKb,
|
||||
execution_time: executionTime ? parseFloat(executionTime) : null,
|
||||
status: "success",
|
||||
},
|
||||
});
|
||||
@@ -1126,12 +1135,16 @@ router.get("/install", async (req, res) => {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Inject the API credentials, server URL, and curl flags into the script
|
||||
// Check for --force parameter
|
||||
const forceInstall = req.query.force === "true" || req.query.force === "1";
|
||||
|
||||
// Inject the API credentials, server URL, curl flags, and force flag into the script
|
||||
const envVars = `#!/bin/bash
|
||||
export PATCHMON_URL="${serverUrl}"
|
||||
export API_ID="${host.api_id}"
|
||||
export API_KEY="${host.api_key}"
|
||||
export CURL_FLAGS="${curlFlags}"
|
||||
export FORCE_INSTALL="${forceInstall ? "true" : "false"}"
|
||||
|
||||
`;
|
||||
|
||||
@@ -1151,6 +1164,48 @@ export CURL_FLAGS="${curlFlags}"
|
||||
}
|
||||
});
|
||||
|
||||
// Check if machine_id already exists (requires auth)
|
||||
router.post("/check-machine-id", validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
const { machine_id } = req.body;
|
||||
|
||||
if (!machine_id) {
|
||||
return res.status(400).json({
|
||||
error: "machine_id is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if a host with this machine_id exists
|
||||
const existing_host = await prisma.hosts.findUnique({
|
||||
where: { machine_id },
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
machine_id: true,
|
||||
api_id: true,
|
||||
status: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing_host) {
|
||||
return res.status(200).json({
|
||||
exists: true,
|
||||
host: existing_host,
|
||||
message: "This machine is already enrolled",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
exists: false,
|
||||
message: "Machine not yet enrolled",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error checking machine_id:", error);
|
||||
res.status(500).json({ error: "Failed to check machine_id" });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve the removal script (public endpoint - no authentication required)
|
||||
router.get("/remove", async (_req, res) => {
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ router.get("/", async (req, res) => {
|
||||
category = "",
|
||||
needsUpdate = "",
|
||||
isSecurityUpdate = "",
|
||||
host = "",
|
||||
} = req.query;
|
||||
|
||||
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
||||
@@ -33,8 +34,27 @@ router.get("/", async (req, res) => {
|
||||
: {},
|
||||
// Category filter
|
||||
category ? { category: { equals: category } } : {},
|
||||
// Update status filters
|
||||
needsUpdate
|
||||
// Host filter - only return packages installed on the specified host
|
||||
// Combined with update status filters if both are present
|
||||
host
|
||||
? {
|
||||
host_packages: {
|
||||
some: {
|
||||
host_id: host,
|
||||
// If needsUpdate or isSecurityUpdate filters are present, apply them here
|
||||
...(needsUpdate
|
||||
? { needs_update: needsUpdate === "true" }
|
||||
: {}),
|
||||
...(isSecurityUpdate
|
||||
? { is_security_update: isSecurityUpdate === "true" }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
// Update status filters (only applied if no host filter)
|
||||
// If host filter is present, these are already applied above
|
||||
!host && needsUpdate
|
||||
? {
|
||||
host_packages: {
|
||||
some: {
|
||||
@@ -43,7 +63,7 @@ router.get("/", async (req, res) => {
|
||||
},
|
||||
}
|
||||
: {},
|
||||
isSecurityUpdate
|
||||
!host && isSecurityUpdate
|
||||
? {
|
||||
host_packages: {
|
||||
some: {
|
||||
@@ -67,7 +87,9 @@ router.get("/", async (req, res) => {
|
||||
latest_version: true,
|
||||
created_at: true,
|
||||
_count: {
|
||||
host_packages: true,
|
||||
select: {
|
||||
host_packages: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
skip,
|
||||
@@ -82,24 +104,32 @@ router.get("/", async (req, res) => {
|
||||
// Get additional stats for each package
|
||||
const packagesWithStats = await Promise.all(
|
||||
packages.map(async (pkg) => {
|
||||
const [updatesCount, securityCount, affectedHosts] = await Promise.all([
|
||||
// Build base where clause for this package
|
||||
const baseWhere = { package_id: pkg.id };
|
||||
|
||||
// If host filter is specified, add host filter to all queries
|
||||
const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere;
|
||||
|
||||
const [updatesCount, securityCount, packageHosts] = await Promise.all([
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
package_id: pkg.id,
|
||||
...hostWhere,
|
||||
needs_update: true,
|
||||
},
|
||||
}),
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
package_id: pkg.id,
|
||||
...hostWhere,
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
}),
|
||||
prisma.host_packages.findMany({
|
||||
where: {
|
||||
package_id: pkg.id,
|
||||
needs_update: true,
|
||||
...hostWhere,
|
||||
// If host filter is specified, include all packages for that host
|
||||
// Otherwise, only include packages that need updates
|
||||
...(host ? {} : { needs_update: true }),
|
||||
},
|
||||
select: {
|
||||
hosts: {
|
||||
@@ -110,6 +140,10 @@ router.get("/", async (req, res) => {
|
||||
os_type: true,
|
||||
},
|
||||
},
|
||||
current_version: true,
|
||||
available_version: true,
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
take: 10, // Limit to first 10 for performance
|
||||
}),
|
||||
@@ -117,17 +151,18 @@ router.get("/", async (req, res) => {
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
affectedHostsCount: pkg._count.hostPackages,
|
||||
affectedHosts: affectedHosts.map((hp) => ({
|
||||
hostId: hp.host.id,
|
||||
friendlyName: hp.host.friendly_name,
|
||||
osType: hp.host.os_type,
|
||||
packageHostsCount: pkg._count.host_packages,
|
||||
packageHosts: packageHosts.map((hp) => ({
|
||||
hostId: hp.hosts.id,
|
||||
friendlyName: hp.hosts.friendly_name,
|
||||
osType: hp.hosts.os_type,
|
||||
currentVersion: hp.current_version,
|
||||
availableVersion: hp.available_version,
|
||||
needsUpdate: hp.needs_update,
|
||||
isSecurityUpdate: hp.is_security_update,
|
||||
})),
|
||||
stats: {
|
||||
totalInstalls: pkg._count.hostPackages,
|
||||
totalInstalls: pkg._count.host_packages,
|
||||
updatesNeeded: updatesCount,
|
||||
securityUpdates: securityCount,
|
||||
},
|
||||
@@ -160,19 +195,19 @@ router.get("/:packageId", async (req, res) => {
|
||||
include: {
|
||||
host_packages: {
|
||||
include: {
|
||||
host: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
lastUpdate: true,
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
last_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
needsUpdate: "desc",
|
||||
needs_update: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -185,25 +220,25 @@ router.get("/:packageId", async (req, res) => {
|
||||
// Calculate statistics
|
||||
const stats = {
|
||||
totalInstalls: packageData.host_packages.length,
|
||||
updatesNeeded: packageData.host_packages.filter((hp) => hp.needsUpdate)
|
||||
updatesNeeded: packageData.host_packages.filter((hp) => hp.needs_update)
|
||||
.length,
|
||||
securityUpdates: packageData.host_packages.filter(
|
||||
(hp) => hp.needsUpdate && hp.isSecurityUpdate,
|
||||
(hp) => hp.needs_update && hp.is_security_update,
|
||||
).length,
|
||||
upToDate: packageData.host_packages.filter((hp) => !hp.needsUpdate)
|
||||
upToDate: packageData.host_packages.filter((hp) => !hp.needs_update)
|
||||
.length,
|
||||
};
|
||||
|
||||
// Group by version
|
||||
const versionDistribution = packageData.host_packages.reduce((acc, hp) => {
|
||||
const version = hp.currentVersion;
|
||||
const version = hp.current_version;
|
||||
acc[version] = (acc[version] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Group by OS type
|
||||
const osDistribution = packageData.host_packages.reduce((acc, hp) => {
|
||||
const osType = hp.host.osType;
|
||||
const osType = hp.hosts.os_type;
|
||||
acc[osType] = (acc[osType] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -230,4 +265,109 @@ router.get("/:packageId", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get hosts where a package is installed
|
||||
router.get("/:packageId/hosts", async (req, res) => {
|
||||
try {
|
||||
const { packageId } = req.params;
|
||||
const {
|
||||
page = 1,
|
||||
limit = 25,
|
||||
search = "",
|
||||
sortBy = "friendly_name",
|
||||
sortOrder = "asc",
|
||||
} = req.query;
|
||||
|
||||
const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
||||
|
||||
// Build search conditions
|
||||
const searchConditions = search
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
hosts: {
|
||||
friendly_name: { contains: search, mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
{ hosts: { hostname: { contains: search, mode: "insensitive" } } },
|
||||
{ current_version: { contains: search, mode: "insensitive" } },
|
||||
{ available_version: { contains: search, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
// Build sort conditions
|
||||
const orderBy = {};
|
||||
if (
|
||||
sortBy === "friendly_name" ||
|
||||
sortBy === "hostname" ||
|
||||
sortBy === "os_type"
|
||||
) {
|
||||
orderBy.hosts = { [sortBy]: sortOrder };
|
||||
} else if (sortBy === "needs_update") {
|
||||
orderBy[sortBy] = sortOrder;
|
||||
} else {
|
||||
orderBy[sortBy] = sortOrder;
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const totalCount = await prisma.host_packages.count({
|
||||
where: {
|
||||
package_id: packageId,
|
||||
...searchConditions,
|
||||
},
|
||||
});
|
||||
|
||||
// Get paginated results
|
||||
const hostPackages = await prisma.host_packages.findMany({
|
||||
where: {
|
||||
package_id: packageId,
|
||||
...searchConditions,
|
||||
},
|
||||
include: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
last_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip: offset,
|
||||
take: parseInt(limit, 10),
|
||||
});
|
||||
|
||||
// Transform the data for the frontend
|
||||
const hosts = hostPackages.map((hp) => ({
|
||||
hostId: hp.hosts.id,
|
||||
friendlyName: hp.hosts.friendly_name,
|
||||
hostname: hp.hosts.hostname,
|
||||
osType: hp.hosts.os_type,
|
||||
osVersion: hp.hosts.os_version,
|
||||
lastUpdate: hp.hosts.last_update,
|
||||
currentVersion: hp.current_version,
|
||||
availableVersion: hp.available_version,
|
||||
needsUpdate: hp.needs_update,
|
||||
isSecurityUpdate: hp.is_security_update,
|
||||
lastChecked: hp.last_checked,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
hosts,
|
||||
pagination: {
|
||||
page: parseInt(page, 10),
|
||||
limit: parseInt(limit, 10),
|
||||
total: totalCount,
|
||||
pages: Math.ceil(totalCount / parseInt(limit, 10)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching package hosts:", error);
|
||||
res.status(500).json({ error: "Failed to fetch package hosts" });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -289,6 +289,77 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a specific repository (admin only)
|
||||
router.delete(
|
||||
"/:repositoryId",
|
||||
authenticateToken,
|
||||
requireManageHosts,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { repositoryId } = req.params;
|
||||
|
||||
// Check if repository exists first
|
||||
const existingRepository = await prisma.repositories.findUnique({
|
||||
where: { id: repositoryId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
_count: {
|
||||
select: {
|
||||
host_repositories: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingRepository) {
|
||||
return res.status(404).json({
|
||||
error: "Repository not found",
|
||||
details: "The repository may have been deleted or does not exist",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete repository and all related data (cascade will handle host_repositories)
|
||||
await prisma.repositories.delete({
|
||||
where: { id: repositoryId },
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "Repository deleted successfully",
|
||||
deletedRepository: {
|
||||
id: existingRepository.id,
|
||||
name: existingRepository.name,
|
||||
url: existingRepository.url,
|
||||
hostCount: existingRepository._count.host_repositories,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Repository deletion error:", error);
|
||||
|
||||
// Handle specific Prisma errors
|
||||
if (error.code === "P2025") {
|
||||
return res.status(404).json({
|
||||
error: "Repository not found",
|
||||
details: "The repository may have been deleted or does not exist",
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === "P2003") {
|
||||
return res.status(400).json({
|
||||
error: "Cannot delete repository due to foreign key constraints",
|
||||
details: "The repository has related data that prevents deletion",
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: "Failed to delete repository",
|
||||
details: error.message || "An unexpected error occurred",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Cleanup orphaned repositories (admin only)
|
||||
router.delete(
|
||||
"/cleanup/orphaned",
|
||||
|
||||
249
backend/src/routes/searchRoutes.js
Normal file
249
backend/src/routes/searchRoutes.js
Normal file
@@ -0,0 +1,249 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { createPrismaClient } = require("../config/database");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
/**
|
||||
* Global search endpoint
|
||||
* Searches across hosts, packages, repositories, and users
|
||||
* Returns categorized results
|
||||
*/
|
||||
router.get("/", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
|
||||
if (!q || q.trim().length === 0) {
|
||||
return res.json({
|
||||
hosts: [],
|
||||
packages: [],
|
||||
repositories: [],
|
||||
users: [],
|
||||
});
|
||||
}
|
||||
|
||||
const searchTerm = q.trim();
|
||||
|
||||
// Prepare results object
|
||||
const results = {
|
||||
hosts: [],
|
||||
packages: [],
|
||||
repositories: [],
|
||||
users: [],
|
||||
};
|
||||
|
||||
// Get user permissions from database
|
||||
let userPermissions = null;
|
||||
try {
|
||||
userPermissions = await prisma.role_permissions.findUnique({
|
||||
where: { role: req.user.role },
|
||||
});
|
||||
|
||||
// If no specific permissions found, default to admin permissions
|
||||
if (!userPermissions) {
|
||||
console.warn(
|
||||
`No permissions found for role: ${req.user.role}, defaulting to admin access`,
|
||||
);
|
||||
userPermissions = {
|
||||
can_view_hosts: true,
|
||||
can_view_packages: true,
|
||||
can_view_users: true,
|
||||
};
|
||||
}
|
||||
} catch (permError) {
|
||||
console.error("Error fetching permissions:", permError);
|
||||
// Default to restrictive permissions on error
|
||||
userPermissions = {
|
||||
can_view_hosts: false,
|
||||
can_view_packages: false,
|
||||
can_view_users: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Search hosts if user has permission
|
||||
if (userPermissions.can_view_hosts) {
|
||||
try {
|
||||
const hosts = await prisma.hosts.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ hostname: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ friendly_name: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ ip: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ machine_id: { contains: searchTerm, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
machine_id: true,
|
||||
hostname: true,
|
||||
friendly_name: true,
|
||||
ip: true,
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
status: true,
|
||||
last_update: true,
|
||||
},
|
||||
take: 10, // Limit results
|
||||
orderBy: {
|
||||
last_update: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
results.hosts = hosts.map((host) => ({
|
||||
id: host.id,
|
||||
hostname: host.hostname,
|
||||
friendly_name: host.friendly_name,
|
||||
ip: host.ip,
|
||||
os_type: host.os_type,
|
||||
os_version: host.os_version,
|
||||
status: host.status,
|
||||
last_update: host.last_update,
|
||||
type: "host",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error searching hosts:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Search packages if user has permission
|
||||
if (userPermissions.can_view_packages) {
|
||||
try {
|
||||
const packages = await prisma.packages.findMany({
|
||||
where: {
|
||||
name: { contains: searchTerm, mode: "insensitive" },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
category: true,
|
||||
latest_version: true,
|
||||
_count: {
|
||||
select: {
|
||||
host_packages: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
results.packages = packages.map((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
category: pkg.category,
|
||||
latest_version: pkg.latest_version,
|
||||
host_count: pkg._count.host_packages,
|
||||
type: "package",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error searching packages:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Search repositories if user has permission (usually same as hosts)
|
||||
if (userPermissions.can_view_hosts) {
|
||||
try {
|
||||
const repositories = await prisma.repositories.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ url: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ description: { contains: searchTerm, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
distribution: true,
|
||||
repo_type: true,
|
||||
is_active: true,
|
||||
description: true,
|
||||
_count: {
|
||||
select: {
|
||||
host_repositories: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
results.repositories = repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
url: repo.url,
|
||||
distribution: repo.distribution,
|
||||
repo_type: repo.repo_type,
|
||||
is_active: repo.is_active,
|
||||
description: repo.description,
|
||||
host_count: repo._count.host_repositories,
|
||||
type: "repository",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error searching repositories:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Search users if user has permission
|
||||
if (userPermissions.can_view_users) {
|
||||
try {
|
||||
const users = await prisma.users.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ email: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ first_name: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ last_name: { contains: searchTerm, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
role: true,
|
||||
is_active: true,
|
||||
last_login: true,
|
||||
},
|
||||
take: 10,
|
||||
orderBy: {
|
||||
username: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
results.users = users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
last_login: user.last_login,
|
||||
type: "user",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error searching users:", error);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error("Global search error:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to perform search",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -215,6 +215,18 @@ router.put(
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body("logoDark")
|
||||
.optional()
|
||||
.isLength({ min: 1 })
|
||||
.withMessage("Logo dark path must be a non-empty string"),
|
||||
body("logoLight")
|
||||
.optional()
|
||||
.isLength({ min: 1 })
|
||||
.withMessage("Logo light path must be a non-empty string"),
|
||||
body("favicon")
|
||||
.optional()
|
||||
.isLength({ min: 1 })
|
||||
.withMessage("Favicon path must be a non-empty string"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -236,6 +248,9 @@ router.put(
|
||||
githubRepoUrl,
|
||||
repositoryType,
|
||||
sshKeyPath,
|
||||
logoDark,
|
||||
logoLight,
|
||||
favicon,
|
||||
} = req.body;
|
||||
|
||||
// Get current settings to check for update interval changes
|
||||
@@ -264,6 +279,9 @@ router.put(
|
||||
if (repositoryType !== undefined)
|
||||
updateData.repository_type = repositoryType;
|
||||
if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath;
|
||||
if (logoDark !== undefined) updateData.logo_dark = logoDark;
|
||||
if (logoLight !== undefined) updateData.logo_light = logoLight;
|
||||
if (favicon !== undefined) updateData.favicon = favicon;
|
||||
|
||||
const updatedSettings = await updateSettings(
|
||||
currentSettings.id,
|
||||
@@ -351,4 +369,175 @@ router.get("/auto-update", async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Upload logo files
|
||||
router.post(
|
||||
"/logos/upload",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { logoType, fileContent, fileName } = req.body;
|
||||
|
||||
if (!logoType || !fileContent) {
|
||||
return res.status(400).json({
|
||||
error: "Logo type and file content are required",
|
||||
});
|
||||
}
|
||||
|
||||
if (!["dark", "light", "favicon"].includes(logoType)) {
|
||||
return res.status(400).json({
|
||||
error: "Logo type must be 'dark', 'light', or 'favicon'",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate file content (basic checks)
|
||||
if (typeof fileContent !== "string") {
|
||||
return res.status(400).json({
|
||||
error: "File content must be a base64 string",
|
||||
});
|
||||
}
|
||||
|
||||
const fs = require("node:fs").promises;
|
||||
const path = require("node:path");
|
||||
const _crypto = require("node:crypto");
|
||||
|
||||
// Create assets directory if it doesn't exist
|
||||
// In development: save to public/assets (served by Vite)
|
||||
// In production: save to dist/assets (served by built app)
|
||||
const isDevelopment = process.env.NODE_ENV !== "production";
|
||||
const assetsDir = isDevelopment
|
||||
? path.join(__dirname, "../../../frontend/public/assets")
|
||||
: path.join(__dirname, "../../../frontend/dist/assets");
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
|
||||
// Determine file extension and path
|
||||
let fileExtension;
|
||||
let fileName_final;
|
||||
|
||||
if (logoType === "favicon") {
|
||||
fileExtension = ".svg";
|
||||
fileName_final = fileName || "logo_square.svg";
|
||||
} else {
|
||||
// Determine extension from file content or use default
|
||||
if (fileContent.startsWith("data:image/png")) {
|
||||
fileExtension = ".png";
|
||||
} else if (fileContent.startsWith("data:image/svg")) {
|
||||
fileExtension = ".svg";
|
||||
} else if (
|
||||
fileContent.startsWith("data:image/jpeg") ||
|
||||
fileContent.startsWith("data:image/jpg")
|
||||
) {
|
||||
fileExtension = ".jpg";
|
||||
} else {
|
||||
fileExtension = ".png"; // Default to PNG
|
||||
}
|
||||
fileName_final = fileName || `logo_${logoType}${fileExtension}`;
|
||||
}
|
||||
|
||||
const filePath = path.join(assetsDir, fileName_final);
|
||||
|
||||
// Handle base64 data URLs
|
||||
let fileBuffer;
|
||||
if (fileContent.startsWith("data:")) {
|
||||
const base64Data = fileContent.split(",")[1];
|
||||
fileBuffer = Buffer.from(base64Data, "base64");
|
||||
} else {
|
||||
// Assume it's already base64
|
||||
fileBuffer = Buffer.from(fileContent, "base64");
|
||||
}
|
||||
|
||||
// Create backup of existing file
|
||||
try {
|
||||
const backupPath = `${filePath}.backup.${Date.now()}`;
|
||||
await fs.copyFile(filePath, backupPath);
|
||||
console.log(`Created backup: ${backupPath}`);
|
||||
} catch (error) {
|
||||
// Ignore if original doesn't exist
|
||||
if (error.code !== "ENOENT") {
|
||||
console.warn("Failed to create backup:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Write new logo file
|
||||
await fs.writeFile(filePath, fileBuffer);
|
||||
|
||||
// Update settings with new logo path
|
||||
const settings = await getSettings();
|
||||
const logoPath = `/assets/${fileName_final}`;
|
||||
|
||||
const updateData = {};
|
||||
if (logoType === "dark") {
|
||||
updateData.logo_dark = logoPath;
|
||||
} else if (logoType === "light") {
|
||||
updateData.logo_light = logoPath;
|
||||
} else if (logoType === "favicon") {
|
||||
updateData.favicon = logoPath;
|
||||
}
|
||||
|
||||
await updateSettings(settings.id, updateData);
|
||||
|
||||
// Get file stats
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
res.json({
|
||||
message: `${logoType} logo uploaded successfully`,
|
||||
fileName: fileName_final,
|
||||
path: logoPath,
|
||||
size: stats.size,
|
||||
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Upload logo error:", error);
|
||||
res.status(500).json({ error: "Failed to upload logo" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Reset logo to default
|
||||
router.post(
|
||||
"/logos/reset",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { logoType } = req.body;
|
||||
|
||||
if (!logoType) {
|
||||
return res.status(400).json({
|
||||
error: "Logo type is required",
|
||||
});
|
||||
}
|
||||
|
||||
if (!["dark", "light", "favicon"].includes(logoType)) {
|
||||
return res.status(400).json({
|
||||
error: "Logo type must be 'dark', 'light', or 'favicon'",
|
||||
});
|
||||
}
|
||||
|
||||
// Get current settings
|
||||
const settings = await getSettings();
|
||||
|
||||
// Clear the custom logo path to revert to default
|
||||
const updateData = {};
|
||||
if (logoType === "dark") {
|
||||
updateData.logo_dark = null;
|
||||
} else if (logoType === "light") {
|
||||
updateData.logo_light = null;
|
||||
} else if (logoType === "favicon") {
|
||||
updateData.favicon = null;
|
||||
}
|
||||
|
||||
await updateSettings(settings.id, updateData);
|
||||
|
||||
res.json({
|
||||
message: `${logoType} logo reset to default successfully`,
|
||||
logoType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Reset logo error:", error);
|
||||
res.status(500).json({ error: "Failed to reset logo" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,36 +2,229 @@ const express = require("express");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requireManageSettings } = require("../middleware/permissions");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { exec } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Default GitHub repository URL
|
||||
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to get current version from package.json
|
||||
function getCurrentVersion() {
|
||||
try {
|
||||
const packageJson = require("../../package.json");
|
||||
return packageJson?.version || "1.2.8";
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
"Could not read version from package.json, using fallback:",
|
||||
packageError.message,
|
||||
);
|
||||
return "1.2.8";
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to parse GitHub repository URL
|
||||
function parseGitHubRepo(repoUrl) {
|
||||
let owner, repo;
|
||||
|
||||
if (repoUrl.includes("git@github.com:")) {
|
||||
const match = repoUrl.match(/git@github\.com:([^/]+)\/([^/]+)\.git/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
} else if (repoUrl.includes("github.com/")) {
|
||||
const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
}
|
||||
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
// Helper function to get latest release from GitHub API
|
||||
async function getLatestRelease(owner, repo) {
|
||||
try {
|
||||
const currentVersion = getCurrentVersion();
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": `PatchMon-Server/${currentVersion}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (
|
||||
errorText.includes("rate limit") ||
|
||||
errorText.includes("API rate limit")
|
||||
) {
|
||||
throw new Error("GitHub API rate limit exceeded");
|
||||
}
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const releaseData = await response.json();
|
||||
return {
|
||||
tagName: releaseData.tag_name,
|
||||
version: releaseData.tag_name.replace("v", ""),
|
||||
publishedAt: releaseData.published_at,
|
||||
htmlUrl: releaseData.html_url,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest release:", error.message);
|
||||
throw error; // Re-throw to be caught by the calling function
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get latest commit from main branch
|
||||
async function getLatestCommit(owner, repo) {
|
||||
try {
|
||||
const currentVersion = getCurrentVersion();
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits/main`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": `PatchMon-Server/${currentVersion}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (
|
||||
errorText.includes("rate limit") ||
|
||||
errorText.includes("API rate limit")
|
||||
) {
|
||||
throw new Error("GitHub API rate limit exceeded");
|
||||
}
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const commitData = await response.json();
|
||||
return {
|
||||
sha: commitData.sha,
|
||||
message: commitData.commit.message,
|
||||
author: commitData.commit.author.name,
|
||||
date: commitData.commit.author.date,
|
||||
htmlUrl: commitData.html_url,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest commit:", error.message);
|
||||
throw error; // Re-throw to be caught by the calling function
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get commit count difference
|
||||
async function getCommitDifference(owner, repo, currentVersion) {
|
||||
// Try both with and without 'v' prefix for compatibility
|
||||
const versionTags = [
|
||||
currentVersion, // Try without 'v' first (new format)
|
||||
`v${currentVersion}`, // Try with 'v' prefix (old format)
|
||||
];
|
||||
|
||||
for (const versionTag of versionTags) {
|
||||
try {
|
||||
// Compare main branch with the released version tag
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${versionTag}...main`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": `PatchMon-Server/${getCurrentVersion()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (
|
||||
errorText.includes("rate limit") ||
|
||||
errorText.includes("API rate limit")
|
||||
) {
|
||||
throw new Error("GitHub API rate limit exceeded");
|
||||
}
|
||||
// If 404, try next tag format
|
||||
if (response.status === 404) {
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const compareData = await response.json();
|
||||
return {
|
||||
commitsBehind: compareData.behind_by || 0, // How many commits main is behind release
|
||||
commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release
|
||||
totalCommits: compareData.total_commits || 0,
|
||||
branchInfo: "main branch vs release",
|
||||
};
|
||||
} catch (error) {
|
||||
// If rate limit, throw immediately
|
||||
if (error.message.includes("rate limit")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If all attempts failed, throw error
|
||||
throw new Error(
|
||||
`Could not find tag '${currentVersion}' or 'v${currentVersion}' in repository`,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to compare version strings (semantic versioning)
|
||||
function compareVersions(version1, version2) {
|
||||
const v1parts = version1.split(".").map(Number);
|
||||
const v2parts = version2.split(".").map(Number);
|
||||
|
||||
const maxLength = Math.max(v1parts.length, v2parts.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const v1part = v1parts[i] || 0;
|
||||
const v2part = v2parts[i] || 0;
|
||||
|
||||
if (v1part > v2part) return 1;
|
||||
if (v1part < v2part) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get current version info
|
||||
router.get("/current", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
// Read version from package.json dynamically
|
||||
let currentVersion = "1.2.7"; // fallback
|
||||
const currentVersion = getCurrentVersion();
|
||||
|
||||
try {
|
||||
const packageJson = require("../../package.json");
|
||||
if (packageJson?.version) {
|
||||
currentVersion = packageJson.version;
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
"Could not read version from package.json, using fallback:",
|
||||
packageError.message,
|
||||
);
|
||||
}
|
||||
// Get settings with cached update info (no GitHub API calls)
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
|
||||
|
||||
// Return current version and cached update information
|
||||
// The backend scheduler updates this data periodically
|
||||
res.json({
|
||||
version: currentVersion,
|
||||
latest_version: settings?.latest_version || null,
|
||||
is_update_available: settings?.is_update_available || false,
|
||||
last_update_check: settings?.last_update_check || null,
|
||||
buildDate: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
github: {
|
||||
repository: githubRepoUrl,
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error getting current version:", error);
|
||||
@@ -44,119 +237,11 @@ router.post(
|
||||
"/test-ssh-key",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { sshKeyPath, githubRepoUrl } = req.body;
|
||||
|
||||
if (!sshKeyPath || !githubRepoUrl) {
|
||||
return res.status(400).json({
|
||||
error: "SSH key path and GitHub repo URL are required",
|
||||
});
|
||||
}
|
||||
|
||||
// Parse repository info
|
||||
let owner, repo;
|
||||
if (githubRepoUrl.includes("git@github.com:")) {
|
||||
const match = githubRepoUrl.match(
|
||||
/git@github\.com:([^/]+)\/([^/]+)\.git/,
|
||||
);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
} else if (githubRepoUrl.includes("github.com/")) {
|
||||
const match = githubRepoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
}
|
||||
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid GitHub repository URL format",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if SSH key file exists and is readable
|
||||
try {
|
||||
require("node:fs").accessSync(sshKeyPath);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
error: "SSH key file not found or not accessible",
|
||||
details: `Cannot access: ${sshKeyPath}`,
|
||||
suggestion:
|
||||
"Check the file path and ensure the application has read permissions",
|
||||
});
|
||||
}
|
||||
|
||||
// Test SSH connection to GitHub
|
||||
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o ConnectTimeout=10`,
|
||||
};
|
||||
|
||||
try {
|
||||
// Test with a simple git command
|
||||
const { stdout } = await execAsync(
|
||||
`git ls-remote --heads ${sshRepoUrl} | head -n 1`,
|
||||
{
|
||||
timeout: 15000,
|
||||
env: env,
|
||||
},
|
||||
);
|
||||
|
||||
if (stdout.trim()) {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "SSH key is working correctly",
|
||||
details: {
|
||||
sshKeyPath,
|
||||
repository: `${owner}/${repo}`,
|
||||
testResult: "Successfully connected to GitHub",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
error: "SSH connection succeeded but no data returned",
|
||||
suggestion: "Check repository access permissions",
|
||||
});
|
||||
}
|
||||
} catch (sshError) {
|
||||
console.error("SSH test error:", sshError.message);
|
||||
|
||||
if (sshError.message.includes("Permission denied")) {
|
||||
return res.status(403).json({
|
||||
error: "SSH key permission denied",
|
||||
details: "The SSH key exists but GitHub rejected the connection",
|
||||
suggestion:
|
||||
"Verify the SSH key is added to the repository as a deploy key with read access",
|
||||
});
|
||||
} else if (sshError.message.includes("Host key verification failed")) {
|
||||
return res.status(403).json({
|
||||
error: "Host key verification failed",
|
||||
suggestion:
|
||||
"This is normal for first-time connections. The key will be added to known_hosts automatically.",
|
||||
});
|
||||
} else if (sshError.message.includes("Connection timed out")) {
|
||||
return res.status(408).json({
|
||||
error: "Connection timed out",
|
||||
suggestion: "Check your internet connection and GitHub status",
|
||||
});
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
error: "SSH connection failed",
|
||||
details: sshError.message,
|
||||
suggestion: "Check the SSH key format and repository URL",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SSH key test error:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to test SSH key",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
async (_req, res) => {
|
||||
res.status(410).json({
|
||||
error:
|
||||
"SSH key testing has been removed. Using default public repository.",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -174,24 +259,94 @@ router.get(
|
||||
return res.status(400).json({ error: "Settings not found" });
|
||||
}
|
||||
|
||||
const currentVersion = "1.2.7";
|
||||
const latestVersion = settings.latest_version || currentVersion;
|
||||
const isUpdateAvailable = settings.update_available || false;
|
||||
const lastUpdateCheck = settings.last_update_check || null;
|
||||
const currentVersion = getCurrentVersion();
|
||||
const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
|
||||
|
||||
let latestRelease = null;
|
||||
let latestCommit = null;
|
||||
let commitDifference = null;
|
||||
|
||||
// Fetch fresh GitHub data if we have valid owner/repo
|
||||
if (owner && repo) {
|
||||
try {
|
||||
const [releaseData, commitData, differenceData] = await Promise.all([
|
||||
getLatestRelease(owner, repo),
|
||||
getLatestCommit(owner, repo),
|
||||
getCommitDifference(owner, repo, currentVersion),
|
||||
]);
|
||||
|
||||
latestRelease = releaseData;
|
||||
latestCommit = commitData;
|
||||
commitDifference = differenceData;
|
||||
} catch (githubError) {
|
||||
console.warn(
|
||||
"Failed to fetch fresh GitHub data:",
|
||||
githubError.message,
|
||||
);
|
||||
|
||||
// Provide fallback data when GitHub API is rate-limited
|
||||
if (
|
||||
githubError.message.includes("rate limit") ||
|
||||
githubError.message.includes("API rate limit")
|
||||
) {
|
||||
console.log("GitHub API rate limited, providing fallback data");
|
||||
latestRelease = {
|
||||
tagName: "1.2.8",
|
||||
version: "1.2.8",
|
||||
publishedAt: "2025-10-02T17:12:53Z",
|
||||
htmlUrl:
|
||||
"https://github.com/PatchMon/PatchMon/releases/tag/1.2.8",
|
||||
};
|
||||
latestCommit = {
|
||||
sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd",
|
||||
message: "Update README.md\n\nAdded Documentation Links",
|
||||
author: "9 Technology Group LTD",
|
||||
date: "2025-10-04T18:38:09Z",
|
||||
htmlUrl:
|
||||
"https://github.com/PatchMon/PatchMon/commit/cc89df161b8ea5d48ff95b0eb405fe69042052cd",
|
||||
};
|
||||
commitDifference = {
|
||||
commitsBehind: 0,
|
||||
commitsAhead: 3, // Main branch is ahead of release
|
||||
totalCommits: 3,
|
||||
branchInfo: "main branch vs release",
|
||||
};
|
||||
} else {
|
||||
// Fall back to cached data for other errors
|
||||
const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||
latestRelease = settings.latest_version
|
||||
? {
|
||||
version: settings.latest_version,
|
||||
tagName: settings.latest_version,
|
||||
publishedAt: null, // Only use date from GitHub API, not cached data
|
||||
// Note: URL may need 'v' prefix depending on actual tag format in repo
|
||||
htmlUrl: `${githubRepoUrl.replace(/\.git$/, "")}/releases/tag/${settings.latest_version}`,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const latestVersion =
|
||||
latestRelease?.version || settings.latest_version || currentVersion;
|
||||
const isUpdateAvailable = latestRelease
|
||||
? compareVersions(latestVersion, currentVersion) > 0
|
||||
: settings.update_available || false;
|
||||
|
||||
res.json({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpdateAvailable,
|
||||
lastUpdateCheck,
|
||||
lastUpdateCheck: settings.last_update_check || null,
|
||||
repositoryType: settings.repository_type || "public",
|
||||
latestRelease: {
|
||||
tagName: latestVersion ? `v${latestVersion}` : null,
|
||||
version: latestVersion,
|
||||
repository: settings.github_repo_url
|
||||
? settings.github_repo_url.split("/").slice(-2).join("/")
|
||||
: null,
|
||||
accessMethod: settings.repository_type === "private" ? "ssh" : "api",
|
||||
github: {
|
||||
repository: githubRepoUrl,
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
latestRelease: latestRelease,
|
||||
latestCommit: latestCommit,
|
||||
commitDifference: commitDifference,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -60,6 +60,8 @@ const {
|
||||
const repositoryRoutes = require("./routes/repositoryRoutes");
|
||||
const versionRoutes = require("./routes/versionRoutes");
|
||||
const tfaRoutes = require("./routes/tfaRoutes");
|
||||
const searchRoutes = require("./routes/searchRoutes");
|
||||
const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
|
||||
const updateScheduler = require("./services/updateScheduler");
|
||||
const { initSettings } = require("./services/settingsService");
|
||||
const { cleanup_expired_sessions } = require("./utils/session_manager");
|
||||
@@ -414,6 +416,12 @@ app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
|
||||
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
|
||||
app.use(`/api/${apiVersion}/version`, versionRoutes);
|
||||
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
|
||||
app.use(`/api/${apiVersion}/search`, searchRoutes);
|
||||
app.use(
|
||||
`/api/${apiVersion}/auto-enrollment`,
|
||||
authLimiter,
|
||||
autoEnrollmentRoutes,
|
||||
);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, _req, res, _next) => {
|
||||
@@ -666,11 +674,16 @@ async function getPermissionBasedPreferences(userRole) {
|
||||
requiredPermission: "can_view_packages",
|
||||
order: 13,
|
||||
},
|
||||
{ cardId: "recentUsers", requiredPermission: "can_view_users", order: 14 },
|
||||
{
|
||||
cardId: "packageTrends",
|
||||
requiredPermission: "can_view_packages",
|
||||
order: 14,
|
||||
},
|
||||
{ cardId: "recentUsers", requiredPermission: "can_view_users", order: 15 },
|
||||
{
|
||||
cardId: "quickStats",
|
||||
requiredPermission: "can_view_dashboard",
|
||||
order: 15,
|
||||
order: 16,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -60,13 +60,8 @@ class UpdateScheduler {
|
||||
|
||||
// Get settings
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (!settings || !settings.githubRepoUrl) {
|
||||
console.log("⚠️ No GitHub repository configured, skipping update check");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract owner and repo from GitHub URL
|
||||
const repoUrl = settings.githubRepoUrl;
|
||||
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
|
||||
const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||
let owner, repo;
|
||||
|
||||
if (repoUrl.includes("git@github.com:")) {
|
||||
@@ -109,7 +104,7 @@ class UpdateScheduler {
|
||||
}
|
||||
|
||||
// Read version from package.json dynamically
|
||||
let currentVersion = "1.2.7"; // fallback
|
||||
let currentVersion = "1.2.8"; // fallback
|
||||
try {
|
||||
const packageJson = require("../../package.json");
|
||||
if (packageJson?.version) {
|
||||
@@ -128,9 +123,9 @@ class UpdateScheduler {
|
||||
await prisma.settings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
lastUpdateCheck: new Date(),
|
||||
updateAvailable: isUpdateAvailable,
|
||||
latestVersion: latestVersion,
|
||||
last_update_check: new Date(),
|
||||
update_available: isUpdateAvailable,
|
||||
latest_version: latestVersion,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -147,8 +142,8 @@ class UpdateScheduler {
|
||||
await prisma.settings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
lastUpdateCheck: new Date(),
|
||||
updateAvailable: false,
|
||||
last_update_check: new Date(),
|
||||
update_available: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -219,7 +214,7 @@ class UpdateScheduler {
|
||||
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||
|
||||
// Get current version for User-Agent
|
||||
let currentVersion = "1.2.7"; // fallback
|
||||
let currentVersion = "1.2.8"; // fallback
|
||||
try {
|
||||
const packageJson = require("../../package.json");
|
||||
if (packageJson?.version) {
|
||||
@@ -241,6 +236,16 @@ class UpdateScheduler {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (
|
||||
errorText.includes("rate limit") ||
|
||||
errorText.includes("API rate limit")
|
||||
) {
|
||||
console.log(
|
||||
"⚠️ GitHub API rate limit exceeded, skipping update check",
|
||||
);
|
||||
return null; // Return null instead of throwing error
|
||||
}
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const crypto = require("crypto");
|
||||
const crypto = require("node:crypto");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@@ -9,9 +9,22 @@ const prisma = new PrismaClient();
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
|
||||
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
|
||||
const TFA_REMEMBER_ME_EXPIRES_IN =
|
||||
process.env.TFA_REMEMBER_ME_EXPIRES_IN || "30d";
|
||||
const TFA_MAX_REMEMBER_SESSIONS = parseInt(
|
||||
process.env.TFA_MAX_REMEMBER_SESSIONS || "5",
|
||||
10,
|
||||
);
|
||||
const TFA_SUSPICIOUS_ACTIVITY_THRESHOLD = parseInt(
|
||||
process.env.TFA_SUSPICIOUS_ACTIVITY_THRESHOLD || "3",
|
||||
10,
|
||||
);
|
||||
const INACTIVITY_TIMEOUT_MINUTES = parseInt(
|
||||
process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30",
|
||||
10,
|
||||
@@ -67,16 +80,136 @@ function parse_expiration(expiration_string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate device fingerprint from request data
|
||||
*/
|
||||
function generate_device_fingerprint(req) {
|
||||
const components = [
|
||||
req.get("user-agent") || "",
|
||||
req.get("accept-language") || "",
|
||||
req.get("accept-encoding") || "",
|
||||
req.ip || "",
|
||||
];
|
||||
|
||||
// Create a simple hash of device characteristics
|
||||
const fingerprint = crypto
|
||||
.createHash("sha256")
|
||||
.update(components.join("|"))
|
||||
.digest("hex")
|
||||
.substring(0, 32); // Use first 32 chars for storage efficiency
|
||||
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for suspicious activity patterns
|
||||
*/
|
||||
async function check_suspicious_activity(
|
||||
user_id,
|
||||
_ip_address,
|
||||
_device_fingerprint,
|
||||
) {
|
||||
try {
|
||||
// Check for multiple sessions from different IPs in short time
|
||||
const recent_sessions = await prisma.user_sessions.findMany({
|
||||
where: {
|
||||
user_id: user_id,
|
||||
created_at: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours
|
||||
},
|
||||
is_revoked: false,
|
||||
},
|
||||
select: {
|
||||
ip_address: true,
|
||||
device_fingerprint: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Count unique IPs and devices
|
||||
const unique_ips = new Set(recent_sessions.map((s) => s.ip_address));
|
||||
const unique_devices = new Set(
|
||||
recent_sessions.map((s) => s.device_fingerprint),
|
||||
);
|
||||
|
||||
// Flag as suspicious if more than threshold different IPs or devices in 24h
|
||||
if (
|
||||
unique_ips.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD ||
|
||||
unique_devices.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD
|
||||
) {
|
||||
console.warn(
|
||||
`Suspicious activity detected for user ${user_id}: ${unique_ips.size} IPs, ${unique_devices.size} devices`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error checking suspicious activity:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for user
|
||||
*/
|
||||
async function create_session(user_id, ip_address, user_agent) {
|
||||
async function create_session(
|
||||
user_id,
|
||||
ip_address,
|
||||
user_agent,
|
||||
remember_me = false,
|
||||
req = null,
|
||||
) {
|
||||
try {
|
||||
const session_id = crypto.randomUUID();
|
||||
const refresh_token = generate_refresh_token();
|
||||
const access_token = generate_access_token(user_id, session_id);
|
||||
|
||||
const expires_at = parse_expiration(JWT_REFRESH_EXPIRES_IN);
|
||||
// Generate device fingerprint if request is available
|
||||
const device_fingerprint = req ? generate_device_fingerprint(req) : null;
|
||||
|
||||
// Check for suspicious activity
|
||||
if (device_fingerprint) {
|
||||
const is_suspicious = await check_suspicious_activity(
|
||||
user_id,
|
||||
ip_address,
|
||||
device_fingerprint,
|
||||
);
|
||||
if (is_suspicious) {
|
||||
console.warn(
|
||||
`Suspicious activity detected for user ${user_id}, session creation may be restricted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check session limits for remember me
|
||||
if (remember_me) {
|
||||
const existing_remember_sessions = await prisma.user_sessions.count({
|
||||
where: {
|
||||
user_id: user_id,
|
||||
tfa_remember_me: true,
|
||||
is_revoked: false,
|
||||
expires_at: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
// Limit remember me sessions per user
|
||||
if (existing_remember_sessions >= TFA_MAX_REMEMBER_SESSIONS) {
|
||||
throw new Error(
|
||||
"Maximum number of remembered devices reached. Please revoke an existing session first.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use longer expiration for remember me sessions
|
||||
const expires_at = remember_me
|
||||
? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN)
|
||||
: parse_expiration(JWT_REFRESH_EXPIRES_IN);
|
||||
|
||||
// Calculate TFA bypass until date for remember me sessions
|
||||
const tfa_bypass_until = remember_me
|
||||
? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN)
|
||||
: null;
|
||||
|
||||
// Store session in database
|
||||
await prisma.user_sessions.create({
|
||||
@@ -87,8 +220,13 @@ async function create_session(user_id, ip_address, user_agent) {
|
||||
access_token_hash: hash_token(access_token),
|
||||
ip_address: ip_address || null,
|
||||
user_agent: user_agent || null,
|
||||
device_fingerprint: device_fingerprint,
|
||||
last_login_ip: ip_address || null,
|
||||
last_activity: new Date(),
|
||||
expires_at: expires_at,
|
||||
tfa_remember_me: remember_me,
|
||||
tfa_bypass_until: tfa_bypass_until,
|
||||
login_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,6 +235,7 @@ async function create_session(user_id, ip_address, user_agent) {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at,
|
||||
tfa_bypass_until,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating session:", error);
|
||||
@@ -296,6 +435,8 @@ async function get_user_sessions(user_id) {
|
||||
last_activity: true,
|
||||
created_at: true,
|
||||
expires_at: true,
|
||||
tfa_remember_me: true,
|
||||
tfa_bypass_until: true,
|
||||
},
|
||||
orderBy: { last_activity: "desc" },
|
||||
});
|
||||
@@ -305,6 +446,42 @@ async function get_user_sessions(user_id) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TFA is bypassed for a session
|
||||
*/
|
||||
async function is_tfa_bypassed(session_id) {
|
||||
try {
|
||||
const session = await prisma.user_sessions.findUnique({
|
||||
where: { id: session_id },
|
||||
select: {
|
||||
tfa_remember_me: true,
|
||||
tfa_bypass_until: true,
|
||||
is_revoked: true,
|
||||
expires_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if session is still valid
|
||||
if (session.is_revoked || new Date() > session.expires_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if TFA is bypassed and still within bypass period
|
||||
if (session.tfa_remember_me && session.tfa_bypass_until) {
|
||||
return new Date() < session.tfa_bypass_until;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error checking TFA bypass:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create_session,
|
||||
validate_session,
|
||||
@@ -314,6 +491,9 @@ module.exports = {
|
||||
revoke_all_user_sessions,
|
||||
cleanup_expired_sessions,
|
||||
get_user_sessions,
|
||||
is_tfa_bypassed,
|
||||
generate_device_fingerprint,
|
||||
check_suspicious_activity,
|
||||
generate_access_token,
|
||||
INACTIVITY_TIMEOUT_MINUTES,
|
||||
};
|
||||
|
||||
@@ -6,14 +6,22 @@ PatchMon is a containerised application that monitors system patches and updates
|
||||
|
||||
- **Database**: PostgreSQL 17
|
||||
- **Backend**: Node.js API server
|
||||
- **Frontend**: React application served via Nginx
|
||||
- **Frontend**: React application served via NGINX
|
||||
|
||||
## Images
|
||||
|
||||
- **Backend**: [ghcr.io/patchmon/patchmon-backend:latest](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-backend)
|
||||
- **Frontend**: [ghcr.io/patchmon/patchmon-frontend:latest](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-frontend)
|
||||
- **Backend**: [ghcr.io/patchmon/patchmon-backend](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-backend)
|
||||
- **Frontend**: [ghcr.io/patchmon/patchmon-frontend](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-frontend)
|
||||
|
||||
Version tags are also available (e.g. `1.2.3`) for both of these images.
|
||||
### Tags
|
||||
|
||||
- `latest`: The latest stable release of PatchMon
|
||||
- `x.y.z`: Full version tags (e.g. `1.2.3`) - Use this for exact version pinning.
|
||||
- `x.y`: Minor version tags (e.g. `1.2`) - Use this to get the latest patch release in a minor version series.
|
||||
- `x`: Major version tags (e.g. `1`) - Use this to get the latest minor and patch release in a major version series.
|
||||
- `edge`: The latest development build with the most recent features and fixes. This tag may often be unstable and is intended only for testing and development purposes.
|
||||
|
||||
These tags are available for both backend and frontend images as they are versioned together.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -8,19 +8,94 @@ log() {
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
|
||||
}
|
||||
|
||||
# Copy files from agents_backup to agents if agents directory is empty
|
||||
if [ -d "/app/agents" ] && [ -z "$(ls -A /app/agents 2>/dev/null)" ]; then
|
||||
if [ -d "/app/agents_backup" ]; then
|
||||
log "Agents directory is empty, copying from backup..."
|
||||
cp -r /app/agents_backup/* /app/agents/
|
||||
# Function to extract version from agent script
|
||||
get_agent_version() {
|
||||
local file="$1"
|
||||
if [ -f "$file" ]; then
|
||||
grep -m 1 '^AGENT_VERSION=' "$file" | cut -d'"' -f2 2>/dev/null || echo "0.0.0"
|
||||
else
|
||||
log "Warning: agents_backup directory not found"
|
||||
echo "0.0.0"
|
||||
fi
|
||||
else
|
||||
log "Agents directory already contains files, skipping copy"
|
||||
fi
|
||||
}
|
||||
|
||||
log "Starting PatchMon Backend (${NODE_ENV:-production})..."
|
||||
# Function to compare versions (returns 0 if $1 > $2)
|
||||
version_greater() {
|
||||
# Use sort -V for version comparison
|
||||
test "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" && test "$1" != "$2"
|
||||
}
|
||||
|
||||
# Check and update agent files if necessary
|
||||
update_agents() {
|
||||
local backup_agent="/app/agents_backup/patchmon-agent.sh"
|
||||
local current_agent="/app/agents/patchmon-agent.sh"
|
||||
|
||||
# Check if agents directory exists
|
||||
if [ ! -d "/app/agents" ]; then
|
||||
log "ERROR: /app/agents directory not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if backup exists
|
||||
if [ ! -d "/app/agents_backup" ]; then
|
||||
log "WARNING: agents_backup directory not found, skipping agent update"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get versions
|
||||
local backup_version=$(get_agent_version "$backup_agent")
|
||||
local current_version=$(get_agent_version "$current_agent")
|
||||
|
||||
log "Agent version check:"
|
||||
log " Image version: ${backup_version}"
|
||||
log " Volume version: ${current_version}"
|
||||
|
||||
# Determine if update is needed
|
||||
local needs_update=0
|
||||
|
||||
# Case 1: No agents in volume (first time setup)
|
||||
if [ -z "$(find /app/agents -maxdepth 1 -type f -name '*.sh' 2>/dev/null | head -n 1)" ]; then
|
||||
log "Agents directory is empty - performing initial copy"
|
||||
needs_update=1
|
||||
# Case 2: Backup version is newer
|
||||
elif version_greater "$backup_version" "$current_version"; then
|
||||
log "Newer agent version available (${backup_version} > ${current_version})"
|
||||
needs_update=1
|
||||
else
|
||||
log "Agents are up to date"
|
||||
needs_update=0
|
||||
fi
|
||||
|
||||
# Perform update if needed
|
||||
if [ $needs_update -eq 1 ]; then
|
||||
log "Updating agents to version ${backup_version}..."
|
||||
|
||||
# Create backup of existing agents if they exist
|
||||
if [ -f "$current_agent" ]; then
|
||||
local backup_timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_name="/app/agents/patchmon-agent.sh.backup.${backup_timestamp}"
|
||||
cp "$current_agent" "$backup_name" 2>/dev/null || true
|
||||
log "Previous agent backed up to: $(basename $backup_name)"
|
||||
fi
|
||||
|
||||
# Copy new agents
|
||||
cp -r /app/agents_backup/* /app/agents/
|
||||
|
||||
# Verify update
|
||||
local new_version=$(get_agent_version "$current_agent")
|
||||
if [ "$new_version" = "$backup_version" ]; then
|
||||
log "✅ Agents successfully updated to version ${new_version}"
|
||||
else
|
||||
log "⚠️ Warning: Agent update may have failed (expected: ${backup_version}, got: ${new_version})"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Main execution
|
||||
log "PatchMon Backend Container Starting..."
|
||||
log "Environment: ${NODE_ENV:-production}"
|
||||
|
||||
# Update agents (version-aware)
|
||||
update_agents
|
||||
|
||||
log "Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "patchmon-frontend",
|
||||
"private": true,
|
||||
"version": "1.2.7",
|
||||
"version": "1.2.8",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
1
frontend/public/assets/favicon.svg
Normal file
1
frontend/public/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="d62632d413"><path d="M 29 28 L 304 28 L 304 350 L 29 350 Z M 29 28 " clip-rule="nonzero"/></clipPath><clipPath id="ecc8b4d8ed"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="3016db942f"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="029f8ae6a8"><path d="M 29 28 L 304 28 L 304 350 L 29 350 Z M 29 28 " clip-rule="nonzero"/></clipPath><clipPath id="2d374b5e76"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="544d823606"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="b88a276116"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="98c26e11a4"><rect x="0" width="103" y="0" height="208"/></clipPath></defs><g clip-path="url(#d62632d413)"><g clip-path="url(#ecc8b4d8ed)"><g clip-path="url(#3016db942f)"><path fill="#ff751f" d="M 303.214844 302.761719 C 280.765625 325.214844 252.160156 340.503906 221.015625 346.699219 C 189.875 352.890625 157.59375 349.714844 128.261719 337.5625 C 98.925781 325.410156 73.851562 304.835938 56.210938 278.433594 C 38.570312 252.03125 29.15625 220.992188 29.15625 189.242188 C 29.15625 157.488281 38.570312 126.449219 56.210938 100.050781 C 73.851562 73.648438 98.925781 53.070312 128.261719 40.921875 C 157.59375 28.769531 189.875 25.589844 221.015625 31.785156 C 252.160156 37.980469 280.765625 53.269531 303.214844 75.722656 L 189.695312 189.242188 Z M 303.214844 302.761719 " fill-opacity="1" fill-rule="nonzero"/></g></g></g><g clip-path="url(#029f8ae6a8)"><g clip-path="url(#2d374b5e76)"><g clip-path="url(#544d823606)"><g clip-path="url(#b88a276116)"><path fill="#61b33a" d="M 303.144531 302.550781 C 280.707031 324.988281 252.117188 340.269531 220.996094 346.460938 C 189.875 352.652344 157.613281 349.472656 128.296875 337.332031 C 98.980469 325.1875 73.921875 304.621094 56.292969 278.238281 C 38.664062 251.851562 29.253906 220.832031 29.253906 189.101562 C 29.253906 157.367188 38.664062 126.347656 56.292969 99.964844 C 73.921875 73.578125 98.980469 53.015625 128.296875 40.871094 C 157.613281 28.726562 189.875 25.550781 220.996094 31.742188 C 252.117188 37.929688 280.707031 53.210938 303.144531 75.652344 L 189.695312 189.101562 Z M 303.144531 302.550781 " fill-opacity="1" fill-rule="nonzero"/></g></g></g></g><g transform="matrix(1, 0, 0, 1, 136, 0)"><g clip-path="url(#98c26e11a4)"><g fill="#ff751f" fill-opacity="1"><g transform="translate(0.457164, 116.403543)"><g><path d="M 19.734375 -18.71875 C 19.734375 -21.664062 20.015625 -24.441406 20.578125 -27.046875 C 21.148438 -29.660156 22.0625 -32.210938 23.3125 -34.703125 C 24.5625 -37.203125 26.207031 -39.359375 28.25 -41.171875 C 33.6875 -47.066406 41.285156 -50.015625 51.046875 -50.015625 C 59.210938 -50.015625 66.46875 -46.953125 72.8125 -40.828125 C 79.164062 -34.703125 82.34375 -27.332031 82.34375 -18.71875 C 82.34375 -9.414062 79.28125 -1.925781 73.15625 3.75 C 67.257812 9.644531 59.890625 12.59375 51.046875 12.59375 C 42.648438 12.59375 35.332031 9.472656 29.09375 3.234375 C 22.851562 -3.003906 19.734375 -10.320312 19.734375 -18.71875 Z M 19.734375 -18.71875 "/></g></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/public/assets/logo_dark.png
Normal file
BIN
frontend/public/assets/logo_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/assets/logo_light.png
Normal file
BIN
frontend/public/assets/logo_light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -1,29 +1,50 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
|
||||
import Layout from "./components/Layout";
|
||||
import LogoProvider from "./components/LogoProvider";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import SettingsLayout from "./components/SettingsLayout";
|
||||
import { isAuthPhase } from "./constants/authPhases";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import HostDetail from "./pages/HostDetail";
|
||||
import Hosts from "./pages/Hosts";
|
||||
import Login from "./pages/Login";
|
||||
import PackageDetail from "./pages/PackageDetail";
|
||||
import Packages from "./pages/Packages";
|
||||
import Profile from "./pages/Profile";
|
||||
import Repositories from "./pages/Repositories";
|
||||
import RepositoryDetail from "./pages/RepositoryDetail";
|
||||
import AlertChannels from "./pages/settings/AlertChannels";
|
||||
import Integrations from "./pages/settings/Integrations";
|
||||
import Notifications from "./pages/settings/Notifications";
|
||||
import PatchManagement from "./pages/settings/PatchManagement";
|
||||
import SettingsAgentConfig from "./pages/settings/SettingsAgentConfig";
|
||||
import SettingsHostGroups from "./pages/settings/SettingsHostGroups";
|
||||
import SettingsServerConfig from "./pages/settings/SettingsServerConfig";
|
||||
import SettingsUsers from "./pages/settings/SettingsUsers";
|
||||
|
||||
// Lazy load pages
|
||||
const Dashboard = lazy(() => import("./pages/Dashboard"));
|
||||
const HostDetail = lazy(() => import("./pages/HostDetail"));
|
||||
const Hosts = lazy(() => import("./pages/Hosts"));
|
||||
const Login = lazy(() => import("./pages/Login"));
|
||||
const PackageDetail = lazy(() => import("./pages/PackageDetail"));
|
||||
const Packages = lazy(() => import("./pages/Packages"));
|
||||
const Profile = lazy(() => import("./pages/Profile"));
|
||||
const Queue = lazy(() => import("./pages/Queue"));
|
||||
const Repositories = lazy(() => import("./pages/Repositories"));
|
||||
const RepositoryDetail = lazy(() => import("./pages/RepositoryDetail"));
|
||||
const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
|
||||
const Integrations = lazy(() => import("./pages/settings/Integrations"));
|
||||
const Notifications = lazy(() => import("./pages/settings/Notifications"));
|
||||
const PatchManagement = lazy(() => import("./pages/settings/PatchManagement"));
|
||||
const SettingsAgentConfig = lazy(
|
||||
() => import("./pages/settings/SettingsAgentConfig"),
|
||||
);
|
||||
const SettingsHostGroups = lazy(
|
||||
() => import("./pages/settings/SettingsHostGroups"),
|
||||
);
|
||||
const SettingsServerConfig = lazy(
|
||||
() => import("./pages/settings/SettingsServerConfig"),
|
||||
);
|
||||
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
|
||||
|
||||
// Loading fallback component
|
||||
const LoadingFallback = () => (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function AppRoutes() {
|
||||
const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
|
||||
@@ -52,275 +73,297 @@ function AppRoutes() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_dashboard">
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/hosts"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<Hosts />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/hosts/:hostId"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<HostDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packages"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_packages">
|
||||
<Layout>
|
||||
<Packages />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/repositories"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<Repositories />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/repositories/:repositoryId"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<RepositoryDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_users">
|
||||
<Layout>
|
||||
<SettingsUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/permissions"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/users"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_users">
|
||||
<Layout>
|
||||
<SettingsUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/roles"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SettingsLayout>
|
||||
<Profile />
|
||||
</SettingsLayout>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/host-groups"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsHostGroups />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/notifications"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsLayout>
|
||||
<Notifications />
|
||||
</SettingsLayout>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/agent-config"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsAgentConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/agent-config/management"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsAgentConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/server-config"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/server-config/version"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/alert-channels"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsLayout>
|
||||
<AlertChannels />
|
||||
</SettingsLayout>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/integrations"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<Integrations />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/patch-management"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<PatchManagement />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/server-url"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/server-version"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/agent-version"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsAgentConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/options"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_hosts">
|
||||
<Layout>
|
||||
<SettingsHostGroups />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packages/:packageId"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_packages">
|
||||
<Layout>
|
||||
<PackageDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_dashboard">
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/hosts"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<Hosts />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/hosts/:hostId"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<HostDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packages"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_packages">
|
||||
<Layout>
|
||||
<Packages />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/repositories"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<Repositories />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/repositories/:repositoryId"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<RepositoryDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/queue"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<Queue />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_users">
|
||||
<Layout>
|
||||
<SettingsUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/permissions"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/users"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_users">
|
||||
<Layout>
|
||||
<SettingsUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/roles"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SettingsLayout>
|
||||
<Profile />
|
||||
</SettingsLayout>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/host-groups"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsHostGroups />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/notifications"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsLayout>
|
||||
<Notifications />
|
||||
</SettingsLayout>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/agent-config"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsAgentConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/agent-config/management"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsAgentConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/server-config"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/server-config/version"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/alert-channels"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsLayout>
|
||||
<AlertChannels />
|
||||
</SettingsLayout>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/integrations"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<Integrations />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/patch-management"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<PatchManagement />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/server-url"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/server-version"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/branding"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsServerConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/agent-version"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsAgentConfig />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/options"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_hosts">
|
||||
<Layout>
|
||||
<SettingsHostGroups />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packages/:packageId"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_packages">
|
||||
<Layout>
|
||||
<PackageDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -329,7 +372,9 @@ function App() {
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<AppRoutes />
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
16
frontend/src/components/DiscordIcon.jsx
Normal file
16
frontend/src/components/DiscordIcon.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
const DiscordIcon = ({ className = "h-5 w-5" }) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Discord"
|
||||
>
|
||||
<title>Discord</title>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscordIcon;
|
||||
428
frontend/src/components/GlobalSearch.jsx
Normal file
428
frontend/src/components/GlobalSearch.jsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import { GitBranch, Package, Search, Server, User, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { searchAPI } from "../utils/api";
|
||||
|
||||
const GlobalSearch = () => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const searchRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Debounce search
|
||||
const debounceTimerRef = useRef(null);
|
||||
|
||||
const performSearch = useCallback(async (searchQuery) => {
|
||||
if (!searchQuery || searchQuery.trim().length === 0) {
|
||||
setResults(null);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await searchAPI.global(searchQuery);
|
||||
setResults(response.data);
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(-1);
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
setResults(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setQuery(value);
|
||||
|
||||
// Clear previous timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
performSearch(value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
// Clear debounce timer to prevent any pending searches
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
setQuery("");
|
||||
setResults(null);
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleResultClick = (result) => {
|
||||
// Navigate based on result type
|
||||
switch (result.type) {
|
||||
case "host":
|
||||
navigate(`/hosts/${result.id}`);
|
||||
break;
|
||||
case "package":
|
||||
navigate(`/packages/${result.id}`);
|
||||
break;
|
||||
case "repository":
|
||||
navigate(`/repositories/${result.id}`);
|
||||
break;
|
||||
case "user":
|
||||
// Users don't have detail pages, so navigate to settings
|
||||
navigate("/settings/users");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Close dropdown and clear
|
||||
handleClear();
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (searchRef.current && !searchRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation
|
||||
const flattenedResults = [];
|
||||
if (results) {
|
||||
if (results.hosts?.length > 0) {
|
||||
flattenedResults.push({ type: "header", label: "Hosts" });
|
||||
flattenedResults.push(...results.hosts);
|
||||
}
|
||||
if (results.packages?.length > 0) {
|
||||
flattenedResults.push({ type: "header", label: "Packages" });
|
||||
flattenedResults.push(...results.packages);
|
||||
}
|
||||
if (results.repositories?.length > 0) {
|
||||
flattenedResults.push({ type: "header", label: "Repositories" });
|
||||
flattenedResults.push(...results.repositories);
|
||||
}
|
||||
if (results.users?.length > 0) {
|
||||
flattenedResults.push({ type: "header", label: "Users" });
|
||||
flattenedResults.push(...results.users);
|
||||
}
|
||||
}
|
||||
|
||||
const navigableResults = flattenedResults.filter((r) => r.type !== "header");
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!isOpen || !results) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < navigableResults.length - 1 ? prev + 1 : prev,
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && navigableResults[selectedIndex]) {
|
||||
handleResultClick(navigableResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon for result type
|
||||
const getResultIcon = (type) => {
|
||||
switch (type) {
|
||||
case "host":
|
||||
return <Server className="h-4 w-4 text-blue-500" />;
|
||||
case "package":
|
||||
return <Package className="h-4 w-4 text-green-500" />;
|
||||
case "repository":
|
||||
return <GitBranch className="h-4 w-4 text-purple-500" />;
|
||||
case "user":
|
||||
return <User className="h-4 w-4 text-orange-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get display text for result
|
||||
const getResultDisplay = (result) => {
|
||||
switch (result.type) {
|
||||
case "host":
|
||||
return {
|
||||
primary: result.friendly_name || result.hostname,
|
||||
secondary: result.ip || result.hostname,
|
||||
};
|
||||
case "package":
|
||||
return {
|
||||
primary: result.name,
|
||||
secondary: result.description || result.category,
|
||||
};
|
||||
case "repository":
|
||||
return {
|
||||
primary: result.name,
|
||||
secondary: result.distribution,
|
||||
};
|
||||
case "user":
|
||||
return {
|
||||
primary: result.username,
|
||||
secondary: result.email,
|
||||
};
|
||||
default:
|
||||
return { primary: "", secondary: "" };
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults =
|
||||
results &&
|
||||
(results.hosts?.length > 0 ||
|
||||
results.packages?.length > 0 ||
|
||||
results.repositories?.length > 0 ||
|
||||
results.users?.length > 0);
|
||||
|
||||
return (
|
||||
<div ref={searchRef} className="relative w-full max-w-sm">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Search className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
|
||||
placeholder="Search hosts, packages, repos, users..."
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
if (query && results) setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown Results */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
||||
Searching...
|
||||
</div>
|
||||
) : hasResults ? (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{/* Hosts */}
|
||||
{results.hosts?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
Hosts
|
||||
</div>
|
||||
{results.hosts.map((host, _idx) => {
|
||||
const display = getResultDisplay(host);
|
||||
const globalIdx = navigableResults.findIndex(
|
||||
(r) => r.id === host.id && r.type === "host",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={host.id}
|
||||
onClick={() => handleResultClick(host)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
{getResultIcon("host")}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">•</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
{host.os_type}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{results.packages?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
Packages
|
||||
</div>
|
||||
{results.packages.map((pkg, _idx) => {
|
||||
const display = getResultDisplay(pkg);
|
||||
const globalIdx = navigableResults.findIndex(
|
||||
(r) => r.id === pkg.id && r.type === "package",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={pkg.id}
|
||||
onClick={() => handleResultClick(pkg)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
{getResultIcon("package")}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
{display.secondary && (
|
||||
<>
|
||||
<span className="text-xs text-secondary-400">
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
{pkg.host_count} hosts
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repositories */}
|
||||
{results.repositories?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
Repositories
|
||||
</div>
|
||||
{results.repositories.map((repo, _idx) => {
|
||||
const display = getResultDisplay(repo);
|
||||
const globalIdx = navigableResults.findIndex(
|
||||
(r) => r.id === repo.id && r.type === "repository",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={repo.id}
|
||||
onClick={() => handleResultClick(repo)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
{getResultIcon("repository")}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">•</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
{repo.host_count} hosts
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users */}
|
||||
{results.users?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
Users
|
||||
</div>
|
||||
{results.users.map((user, _idx) => {
|
||||
const display = getResultDisplay(user);
|
||||
const globalIdx = navigableResults.findIndex(
|
||||
(r) => r.id === user.id && r.type === "user",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={user.id}
|
||||
onClick={() => handleResultClick(user)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
{getResultIcon("user")}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">•</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
{user.role}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : query.trim() ? (
|
||||
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
||||
No results found for "{query}"
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSearch;
|
||||
File diff suppressed because one or more lines are too long
44
frontend/src/components/Logo.jsx
Normal file
44
frontend/src/components/Logo.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import { settingsAPI } from "../utils/api";
|
||||
|
||||
const Logo = ({
|
||||
className = "h-8 w-auto",
|
||||
alt = "PatchMon Logo",
|
||||
...props
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Determine which logo to use based on theme
|
||||
const logoSrc = isDark
|
||||
? settings?.logo_dark || "/assets/logo_dark.png"
|
||||
: settings?.logo_light || "/assets/logo_light.png";
|
||||
|
||||
// Add cache-busting parameter using updated_at timestamp
|
||||
const cacheBuster = settings?.updated_at
|
||||
? new Date(settings.updated_at).getTime()
|
||||
: Date.now();
|
||||
const logoSrcWithCache = `${logoSrc}?v=${cacheBuster}`;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={logoSrcWithCache}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={(e) => {
|
||||
// Fallback to default logo if custom logo fails to load
|
||||
e.target.src = isDark
|
||||
? "/assets/logo_dark.png"
|
||||
: "/assets/logo_light.png";
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
42
frontend/src/components/LogoProvider.jsx
Normal file
42
frontend/src/components/LogoProvider.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { isAuthReady } from "../constants/authPhases";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { settingsAPI } from "../utils/api";
|
||||
|
||||
const LogoProvider = ({ children }) => {
|
||||
const { authPhase, isAuthenticated } = useAuth();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Use custom favicon or fallback to default
|
||||
const faviconUrl = settings?.favicon || "/assets/favicon.svg";
|
||||
|
||||
// Add cache-busting parameter using updated_at timestamp
|
||||
const cacheBuster = settings?.updated_at
|
||||
? new Date(settings.updated_at).getTime()
|
||||
: Date.now();
|
||||
const faviconUrlWithCache = `${faviconUrl}?v=${cacheBuster}`;
|
||||
|
||||
// Update favicon
|
||||
const favicon = document.querySelector('link[rel="icon"]');
|
||||
if (favicon) {
|
||||
favicon.href = faviconUrlWithCache;
|
||||
} else {
|
||||
// Create favicon link if it doesn't exist
|
||||
const link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.href = faviconUrlWithCache;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}, [settings?.favicon, settings?.updated_at]);
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default LogoProvider;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronRight,
|
||||
Code,
|
||||
Folder,
|
||||
Image,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Shield,
|
||||
@@ -81,6 +82,7 @@ const SettingsLayout = ({ children }) => {
|
||||
name: "Alert Channels",
|
||||
href: "/settings/alert-channels",
|
||||
icon: Bell,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
name: "Notifications",
|
||||
@@ -117,7 +119,6 @@ const SettingsLayout = ({ children }) => {
|
||||
name: "Integrations",
|
||||
href: "/settings/integrations",
|
||||
icon: Wrench,
|
||||
comingSoon: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -130,6 +131,11 @@ const SettingsLayout = ({ children }) => {
|
||||
href: "/settings/server-url",
|
||||
icon: Wrench,
|
||||
},
|
||||
{
|
||||
name: "Branding",
|
||||
href: "/settings/branding",
|
||||
icon: Image,
|
||||
},
|
||||
{
|
||||
name: "Server Version",
|
||||
href: "/settings/server-version",
|
||||
|
||||
531
frontend/src/components/settings/BrandingTab.jsx
Normal file
531
frontend/src/components/settings/BrandingTab.jsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { settingsAPI } from "../../utils/api";
|
||||
|
||||
const BrandingTab = () => {
|
||||
// Logo management state
|
||||
const [logoUploadState, setLogoUploadState] = useState({
|
||||
dark: { uploading: false, error: null },
|
||||
light: { uploading: false, error: null },
|
||||
favicon: { uploading: false, error: null },
|
||||
});
|
||||
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
||||
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current settings
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Logo upload mutation
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: ({ logoType, fileContent, fileName }) =>
|
||||
fetch("/api/v1/settings/logos/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
body: JSON.stringify({ logoType, fileContent, fileName }),
|
||||
}).then((res) => res.json()),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries(["settings"]);
|
||||
setLogoUploadState((prev) => ({
|
||||
...prev,
|
||||
[variables.logoType]: { uploading: false, error: null },
|
||||
}));
|
||||
setShowLogoUploadModal(false);
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
console.error("Upload logo error:", error);
|
||||
setLogoUploadState((prev) => ({
|
||||
...prev,
|
||||
[variables.logoType]: {
|
||||
uploading: false,
|
||||
error: error.message || "Failed to upload logo",
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Logo reset mutation
|
||||
const resetLogoMutation = useMutation({
|
||||
mutationFn: (logoType) =>
|
||||
fetch("/api/v1/settings/logos/reset", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
body: JSON.stringify({ logoType }),
|
||||
}).then((res) => res.json()),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["settings"]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Reset logo error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error loading settings
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{error.response?.data?.error || "Failed to load settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<Image className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Logo & Branding
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||
Customize your PatchMon installation with custom logos and favicon.
|
||||
These will be displayed throughout the application.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Dark Logo */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Dark Logo
|
||||
</h4>
|
||||
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
|
||||
<img
|
||||
src={`${settings?.logo_dark || "/assets/logo_dark.png"}?v=${Date.now()}`}
|
||||
alt="Dark Logo"
|
||||
className="max-h-16 max-w-full object-contain"
|
||||
onError={(e) => {
|
||||
e.target.src = "/assets/logo_dark.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
|
||||
{settings?.logo_dark
|
||||
? settings.logo_dark.split("/").pop()
|
||||
: "logo_dark.png (Default)"}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedLogoType("dark");
|
||||
setShowLogoUploadModal(true);
|
||||
}}
|
||||
disabled={logoUploadState.dark.uploading}
|
||||
className="w-full btn-outline flex items-center justify-center gap-2"
|
||||
>
|
||||
{logoUploadState.dark.uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Dark Logo
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{settings?.logo_dark && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetLogoMutation.mutate("dark")}
|
||||
disabled={resetLogoMutation.isPending}
|
||||
className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset to Default
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{logoUploadState.dark.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
{logoUploadState.dark.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Light Logo */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Light Logo
|
||||
</h4>
|
||||
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
|
||||
<img
|
||||
src={`${settings?.logo_light || "/assets/logo_light.png"}?v=${Date.now()}`}
|
||||
alt="Light Logo"
|
||||
className="max-h-16 max-w-full object-contain"
|
||||
onError={(e) => {
|
||||
e.target.src = "/assets/logo_light.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
|
||||
{settings?.logo_light
|
||||
? settings.logo_light.split("/").pop()
|
||||
: "logo_light.png (Default)"}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedLogoType("light");
|
||||
setShowLogoUploadModal(true);
|
||||
}}
|
||||
disabled={logoUploadState.light.uploading}
|
||||
className="w-full btn-outline flex items-center justify-center gap-2"
|
||||
>
|
||||
{logoUploadState.light.uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Light Logo
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{settings?.logo_light && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetLogoMutation.mutate("light")}
|
||||
disabled={resetLogoMutation.isPending}
|
||||
className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset to Default
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{logoUploadState.light.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
{logoUploadState.light.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favicon */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Favicon
|
||||
</h4>
|
||||
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
|
||||
<img
|
||||
src={`${settings?.favicon || "/assets/favicon.svg"}?v=${Date.now()}`}
|
||||
alt="Favicon"
|
||||
className="h-8 w-8 object-contain"
|
||||
onError={(e) => {
|
||||
e.target.src = "/assets/favicon.svg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
|
||||
{settings?.favicon
|
||||
? settings.favicon.split("/").pop()
|
||||
: "favicon.svg (Default)"}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedLogoType("favicon");
|
||||
setShowLogoUploadModal(true);
|
||||
}}
|
||||
disabled={logoUploadState.favicon.uploading}
|
||||
className="w-full btn-outline flex items-center justify-center gap-2"
|
||||
>
|
||||
{logoUploadState.favicon.uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Favicon
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{settings?.favicon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetLogoMutation.mutate("favicon")}
|
||||
disabled={resetLogoMutation.isPending}
|
||||
className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset to Default
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{logoUploadState.favicon.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
{logoUploadState.favicon.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Instructions */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md p-4 mt-6">
|
||||
<div className="flex">
|
||||
<Image className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
Logo Usage
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
<p className="mb-2">
|
||||
These logos are used throughout the application:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<strong>Dark Logo:</strong> Used in dark mode and on light
|
||||
backgrounds
|
||||
</li>
|
||||
<li>
|
||||
<strong>Light Logo:</strong> Used in light mode and on dark
|
||||
backgrounds
|
||||
</li>
|
||||
<li>
|
||||
<strong>Favicon:</strong> Used as the browser tab icon (SVG
|
||||
recommended)
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-3 text-xs">
|
||||
<strong>Supported formats:</strong> PNG, JPG, SVG |{" "}
|
||||
<strong>Max size:</strong> 5MB |{" "}
|
||||
<strong>Recommended sizes:</strong> 200x60px for logos, 32x32px
|
||||
for favicon.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo Upload Modal */}
|
||||
{showLogoUploadModal && (
|
||||
<LogoUploadModal
|
||||
isOpen={showLogoUploadModal}
|
||||
onClose={() => setShowLogoUploadModal(false)}
|
||||
onSubmit={uploadLogoMutation.mutate}
|
||||
isLoading={uploadLogoMutation.isPending}
|
||||
error={uploadLogoMutation.error}
|
||||
logoType={selectedLogoType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Logo Upload Modal Component
|
||||
const LogoUploadModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
error,
|
||||
logoType,
|
||||
}) => {
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/svg+xml",
|
||||
];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
setUploadError("Please select a PNG, JPG, or SVG file");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB limit)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("File size must be less than 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
setUploadError("");
|
||||
|
||||
// Create preview URL
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
setUploadError("");
|
||||
|
||||
if (!selectedFile) {
|
||||
setUploadError("Please select a file");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert file to base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const base64 = event.target.result;
|
||||
onSubmit({
|
||||
logoType,
|
||||
fileContent: base64,
|
||||
fileName: selectedFile.name,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(selectedFile);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl(null);
|
||||
setUploadError("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Upload{" "}
|
||||
{logoType === "favicon"
|
||||
? "Favicon"
|
||||
: `${logoType.charAt(0).toUpperCase() + logoType.slice(1)} Logo`}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Select File
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
|
||||
onChange={handleFileSelect}
|
||||
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Supported formats: PNG, JPG, SVG. Max size: 5MB.
|
||||
{logoType === "favicon"
|
||||
? " Recommended: 32x32px SVG."
|
||||
: " Recommended: 200x60px."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{previewUrl && (
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Preview
|
||||
</div>
|
||||
<div className="flex items-center justify-center p-4 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-600">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className={`object-contain ${
|
||||
logoType === "favicon" ? "h-8 w-8" : "max-h-16 max-w-full"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(uploadError || error) && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
{uploadError ||
|
||||
error?.response?.data?.error ||
|
||||
error?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
|
||||
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p className="font-medium">Important:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1">
|
||||
<li>This will replace the current {logoType} logo</li>
|
||||
<li>A backup will be created automatically</li>
|
||||
<li>The change will be applied immediately</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button type="button" onClick={handleClose} className="btn-outline">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !selectedFile}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isLoading ? "Uploading..." : "Upload Logo"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandingTab;
|
||||
@@ -54,7 +54,7 @@ const UsersTab = () => {
|
||||
});
|
||||
|
||||
// Update user mutation
|
||||
const updateUserMutation = useMutation({
|
||||
const _updateUserMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
@@ -92,7 +92,12 @@ const UsersTab = () => {
|
||||
};
|
||||
|
||||
const handleEditUser = (user) => {
|
||||
setEditingUser(user);
|
||||
// Reset editingUser first to force re-render with fresh data
|
||||
setEditingUser(null);
|
||||
// Use setTimeout to ensure the modal re-initializes with fresh data
|
||||
setTimeout(() => {
|
||||
setEditingUser(user);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleResetPassword = (user) => {
|
||||
@@ -314,7 +319,8 @@ const UsersTab = () => {
|
||||
user={editingUser}
|
||||
isOpen={!!editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onUserUpdated={() => updateUserMutation.mutate()}
|
||||
onUpdateUser={updateUserMutation.mutate}
|
||||
isLoading={updateUserMutation.isPending}
|
||||
roles={roles}
|
||||
/>
|
||||
)}
|
||||
@@ -352,11 +358,29 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Reset form when modal is closed
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setFormData({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
role: "user",
|
||||
});
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
// Only send role if roles are available from API
|
||||
@@ -364,12 +388,19 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
};
|
||||
if (roles && Array.isArray(roles) && roles.length > 0) {
|
||||
payload.role = formData.role;
|
||||
}
|
||||
await adminUsersAPI.create(payload);
|
||||
setSuccess(true);
|
||||
onUserCreated();
|
||||
// Auto-close after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || "Failed to create user");
|
||||
} finally {
|
||||
@@ -517,6 +548,17 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
User created successfully!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">
|
||||
@@ -548,7 +590,14 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
};
|
||||
|
||||
// Edit User Modal Component
|
||||
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
const EditUserModal = ({
|
||||
user,
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpdateUser,
|
||||
isLoading,
|
||||
roles,
|
||||
}) => {
|
||||
const editUsernameId = useId();
|
||||
const editEmailId = useId();
|
||||
const editFirstNameId = useId();
|
||||
@@ -564,21 +613,45 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
role: user?.role || "user",
|
||||
is_active: user?.is_active ?? true,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Update formData when user prop changes or modal opens
|
||||
useEffect(() => {
|
||||
if (user && isOpen) {
|
||||
setFormData({
|
||||
username: user.username || "",
|
||||
email: user.email || "",
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
role: user.role || "user",
|
||||
is_active: user.is_active ?? true,
|
||||
});
|
||||
}
|
||||
}, [user, isOpen]);
|
||||
|
||||
// Reset error and success when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
await adminUsersAPI.update(user.id, formData);
|
||||
onUserUpdated();
|
||||
await onUpdateUser({ id: user.id, data: formData });
|
||||
setSuccess(true);
|
||||
// Auto-close after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || "Failed to update user");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -718,6 +791,17 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
User updated successfully!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Code,
|
||||
Download,
|
||||
Save,
|
||||
ExternalLink,
|
||||
GitCommit,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { settingsAPI, versionAPI } from "../../utils/api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { versionAPI } from "../../utils/api";
|
||||
|
||||
const VersionUpdateTab = () => {
|
||||
const repoPublicId = useId();
|
||||
const repoPrivateId = useId();
|
||||
const useCustomSshKeyId = useId();
|
||||
const githubRepoUrlId = useId();
|
||||
const sshKeyPathId = useId();
|
||||
const [formData, setFormData] = useState({
|
||||
githubRepoUrl: "git@github.com:9technologygroup/patchmon.net.git",
|
||||
repositoryType: "public",
|
||||
sshKeyPath: "",
|
||||
useCustomSshKey: false,
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Version checking state
|
||||
const [versionInfo, setVersionInfo] = useState({
|
||||
currentVersion: null,
|
||||
@@ -32,89 +18,11 @@ const VersionUpdateTab = () => {
|
||||
isUpdateAvailable: false,
|
||||
checking: false,
|
||||
error: null,
|
||||
github: null,
|
||||
});
|
||||
|
||||
const [sshTestResult, setSshTestResult] = useState({
|
||||
testing: false,
|
||||
success: null,
|
||||
message: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current settings
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Update form data when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const newFormData = {
|
||||
githubRepoUrl:
|
||||
settings.github_repo_url ||
|
||||
"git@github.com:9technologygroup/patchmon.net.git",
|
||||
repositoryType: settings.repository_type || "public",
|
||||
sshKeyPath: settings.ssh_key_path || "",
|
||||
useCustomSshKey: !!settings.ssh_key_path,
|
||||
};
|
||||
setFormData(newFormData);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Update settings mutation
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: (data) => {
|
||||
return settingsAPI.update(data).then((res) => res.data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["settings"]);
|
||||
setIsDirty(false);
|
||||
setErrors({});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.response?.data?.errors) {
|
||||
setErrors(
|
||||
error.response.data.errors.reduce((acc, err) => {
|
||||
acc[err.path] = err.msg;
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
} else {
|
||||
setErrors({
|
||||
general: error.response?.data?.error || "Failed to update settings",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Load current version on component mount
|
||||
useEffect(() => {
|
||||
const loadCurrentVersion = async () => {
|
||||
try {
|
||||
const response = await versionAPI.getCurrent();
|
||||
const data = response.data;
|
||||
setVersionInfo((prev) => ({
|
||||
...prev,
|
||||
currentVersion: data.version,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error loading current version:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadCurrentVersion();
|
||||
}, []);
|
||||
|
||||
// Version checking functions
|
||||
const checkForUpdates = async () => {
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
|
||||
|
||||
try {
|
||||
@@ -126,6 +34,7 @@ const VersionUpdateTab = () => {
|
||||
latestVersion: data.latestVersion,
|
||||
isUpdateAvailable: data.isUpdateAvailable,
|
||||
last_update_check: data.last_update_check,
|
||||
github: data.github,
|
||||
checking: false,
|
||||
error: null,
|
||||
});
|
||||
@@ -137,434 +46,276 @@ const VersionUpdateTab = () => {
|
||||
error: error.response?.data?.error || "Failed to check for updates",
|
||||
}));
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const testSshKey = async () => {
|
||||
if (!formData.sshKeyPath || !formData.githubRepoUrl) {
|
||||
setSshTestResult({
|
||||
testing: false,
|
||||
success: false,
|
||||
message: null,
|
||||
error: "Please enter both SSH key path and GitHub repository URL",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Load current version and automatically check for updates on component mount
|
||||
useEffect(() => {
|
||||
const loadAndCheckUpdates = async () => {
|
||||
try {
|
||||
// First, get current version info
|
||||
const response = await versionAPI.getCurrent();
|
||||
const data = response.data;
|
||||
setVersionInfo({
|
||||
currentVersion: data.version,
|
||||
latestVersion: data.latest_version || null,
|
||||
isUpdateAvailable: data.is_update_available || false,
|
||||
last_update_check: data.last_update_check || null,
|
||||
github: data.github,
|
||||
checking: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
setSshTestResult({
|
||||
testing: true,
|
||||
success: null,
|
||||
message: null,
|
||||
error: null,
|
||||
});
|
||||
// Then automatically trigger a fresh update check
|
||||
await checkForUpdates();
|
||||
} catch (error) {
|
||||
console.error("Error loading version info:", error);
|
||||
setVersionInfo((prev) => ({
|
||||
...prev,
|
||||
error: "Failed to load version information",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await versionAPI.testSshKey({
|
||||
sshKeyPath: formData.sshKeyPath,
|
||||
githubRepoUrl: formData.githubRepoUrl,
|
||||
});
|
||||
|
||||
setSshTestResult({
|
||||
testing: false,
|
||||
success: true,
|
||||
message: response.data.message,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SSH key test error:", error);
|
||||
setSshTestResult({
|
||||
testing: false,
|
||||
success: false,
|
||||
message: null,
|
||||
error: error.response?.data?.error || "Failed to test SSH key",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
setIsDirty(true);
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Only include sshKeyPath if the toggle is enabled
|
||||
const dataToSubmit = { ...formData };
|
||||
if (!dataToSubmit.useCustomSshKey) {
|
||||
dataToSubmit.sshKeyPath = "";
|
||||
}
|
||||
// Remove the frontend-only field
|
||||
delete dataToSubmit.useCustomSshKey;
|
||||
|
||||
updateSettingsMutation.mutate(dataToSubmit);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error loading settings
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{error.response?.data?.error || "Failed to load settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
loadAndCheckUpdates();
|
||||
}, [checkForUpdates]); // Run when component mounts
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{errors.general && (
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
{errors.general}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center mb-6">
|
||||
<Code className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Server Version Management
|
||||
Server Version Information
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Version Check Configuration
|
||||
Version Information
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Configure automatic version checking against your GitHub repository to
|
||||
notify users of available updates.
|
||||
Current server version and latest updates from GitHub repository.
|
||||
{versionInfo.checking && (
|
||||
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||||
🔄 Checking for updates...
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<fieldset>
|
||||
<legend className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Repository Type
|
||||
</legend>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id={repoPublicId}
|
||||
name="repositoryType"
|
||||
value="public"
|
||||
checked={formData.repositoryType === "public"}
|
||||
onChange={(e) =>
|
||||
handleInputChange("repositoryType", e.target.value)
|
||||
}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
|
||||
/>
|
||||
<label
|
||||
htmlFor={repoPublicId}
|
||||
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
|
||||
>
|
||||
Public Repository (uses GitHub API - no authentication
|
||||
required)
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id={repoPrivateId}
|
||||
name="repositoryType"
|
||||
value="private"
|
||||
checked={formData.repositoryType === "private"}
|
||||
onChange={(e) =>
|
||||
handleInputChange("repositoryType", e.target.value)
|
||||
}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
|
||||
/>
|
||||
<label
|
||||
htmlFor={repoPrivateId}
|
||||
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
|
||||
>
|
||||
Private Repository (uses SSH with deploy key)
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* My Version */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
My Version
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Choose whether your repository is public or private to determine
|
||||
the appropriate access method.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={githubRepoUrlId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||
>
|
||||
GitHub Repository URL
|
||||
</label>
|
||||
<input
|
||||
id={githubRepoUrlId}
|
||||
type="text"
|
||||
value={formData.githubRepoUrl || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange("githubRepoUrl", e.target.value)
|
||||
}
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
placeholder="git@github.com:username/repository.git"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
SSH or HTTPS URL to your GitHub repository
|
||||
</p>
|
||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||
{versionInfo.currentVersion}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{formData.repositoryType === "private" && (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={useCustomSshKeyId}
|
||||
checked={formData.useCustomSshKey}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
handleInputChange("useCustomSshKey", checked);
|
||||
if (!checked) {
|
||||
handleInputChange("sshKeyPath", "");
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor={useCustomSshKeyId}
|
||||
className="text-sm font-medium text-secondary-700 dark:text-secondary-200"
|
||||
>
|
||||
Set custom SSH key path
|
||||
</label>
|
||||
{/* Latest Release */}
|
||||
{versionInfo.github?.latestRelease && (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Latest Release
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||
{versionInfo.github.latestRelease.tagName}
|
||||
</span>
|
||||
{versionInfo.github.latestRelease.publishedAt && (
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Published:{" "}
|
||||
{new Date(
|
||||
versionInfo.github.latestRelease.publishedAt,
|
||||
).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GitHub Repository Information */}
|
||||
{versionInfo.github && (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600 mt-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Code className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
GitHub Repository Information
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Repository URL */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
|
||||
Repository
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
||||
{versionInfo.github.owner}/{versionInfo.github.repo}
|
||||
</span>
|
||||
{versionInfo.github.repository && (
|
||||
<a
|
||||
href={versionInfo.github.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.useCustomSshKey && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={sshKeyPathId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||
>
|
||||
SSH Key Path
|
||||
</label>
|
||||
<input
|
||||
id={sshKeyPathId}
|
||||
type="text"
|
||||
value={formData.sshKeyPath || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange("sshKeyPath", e.target.value)
|
||||
}
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
placeholder="/root/.ssh/id_ed25519"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Path to your SSH deploy key. If not set, will auto-detect
|
||||
from common locations.
|
||||
</p>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={testSshKey}
|
||||
disabled={
|
||||
sshTestResult.testing ||
|
||||
!formData.sshKeyPath ||
|
||||
!formData.githubRepoUrl
|
||||
}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sshTestResult.testing ? "Testing..." : "Test SSH Key"}
|
||||
</button>
|
||||
|
||||
{sshTestResult.success && (
|
||||
<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
{sshTestResult.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sshTestResult.error && (
|
||||
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mr-2" />
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
{sshTestResult.error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Latest Release Info */}
|
||||
{versionInfo.github.latestRelease && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
|
||||
Release Link
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{versionInfo.github.latestRelease.htmlUrl && (
|
||||
<a
|
||||
href={versionInfo.github.latestRelease.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm"
|
||||
>
|
||||
View Release{" "}
|
||||
<ExternalLink className="h-3 w-3 inline ml-1" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!formData.useCustomSshKey && (
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Using auto-detection for SSH key location
|
||||
</p>
|
||||
{/* Branch Status */}
|
||||
{versionInfo.github.commitDifference && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
|
||||
Branch Status
|
||||
</span>
|
||||
<div className="text-sm">
|
||||
{versionInfo.github.commitDifference.commitsAhead > 0 ? (
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
🚀 Main branch is{" "}
|
||||
{versionInfo.github.commitDifference.commitsAhead}{" "}
|
||||
commits ahead of release
|
||||
</span>
|
||||
) : versionInfo.github.commitDifference.commitsBehind >
|
||||
0 ? (
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
📊 Main branch is{" "}
|
||||
{versionInfo.github.commitDifference.commitsBehind}{" "}
|
||||
commits behind release
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
✅ Main branch is in sync with release
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Current Version
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||
{versionInfo.currentVersion}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Latest Version
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||
{versionInfo.checking ? (
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
Checking...
|
||||
{/* Latest Commit Information */}
|
||||
{versionInfo.github.latestCommit && (
|
||||
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GitCommit className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
|
||||
Latest Commit (Rolling)
|
||||
</span>
|
||||
) : versionInfo.latestVersion ? (
|
||||
<span
|
||||
className={
|
||||
versionInfo.isUpdateAvailable
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}
|
||||
>
|
||||
{versionInfo.latestVersion}
|
||||
{versionInfo.isUpdateAvailable && " (Update Available!)"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-secondary-500 dark:text-secondary-400">
|
||||
Not checked
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Checked Time */}
|
||||
{versionInfo.last_update_check && (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Last Checked
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{new Date(versionInfo.last_update_check).toLocaleString()}
|
||||
</span>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
Updates are checked automatically every 24 hours
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkForUpdates}
|
||||
disabled={versionInfo.checking}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{versionInfo.checking ? "Checking..." : "Check for Updates"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Save Button for Version Settings */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || updateSettingsMutation.isPending}
|
||||
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
|
||||
!isDirty || updateSettingsMutation.isPending
|
||||
? "bg-secondary-400 cursor-not-allowed"
|
||||
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
}`}
|
||||
>
|
||||
{updateSettingsMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versionInfo.error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Version Check Failed
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{versionInfo.error}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono text-secondary-900 dark:text-white">
|
||||
{versionInfo.github.latestCommit.sha.substring(0, 8)}
|
||||
</span>
|
||||
{versionInfo.github.latestCommit.htmlUrl && (
|
||||
<a
|
||||
href={versionInfo.github.latestCommit.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
{versionInfo.github.latestCommit.message.split("\n")[0]}
|
||||
</p>
|
||||
{versionInfo.error.includes("private") && (
|
||||
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
For private repositories, you may need to configure GitHub
|
||||
authentication or make the repository public.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<span>
|
||||
Author: {versionInfo.github.latestCommit.author}
|
||||
</span>
|
||||
<span>
|
||||
Date:{" "}
|
||||
{new Date(
|
||||
versionInfo.github.latestCommit.date,
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message for Version Settings */}
|
||||
{updateSettingsMutation.isSuccess && (
|
||||
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
Settings saved successfully!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Last Checked Time */}
|
||||
{versionInfo.last_update_check && (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600 mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||
Last Checked
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{new Date(versionInfo.last_update_check).toLocaleString()}
|
||||
</span>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
Updates are checked automatically every 24 hours
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-start mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkForUpdates}
|
||||
disabled={versionInfo.checking}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{versionInfo.checking ? "Checking..." : "Check for Updates"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versionInfo.error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4 mt-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Version Check Failed
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{versionInfo.error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { isAuthReady } from "../constants/authPhases";
|
||||
import { settingsAPI, versionAPI } from "../utils/api";
|
||||
import { settingsAPI } from "../utils/api";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
const UpdateNotificationContext = createContext();
|
||||
@@ -21,6 +21,7 @@ export const UpdateNotificationProvider = ({ children }) => {
|
||||
const { authPhase, isAuthenticated } = useAuth();
|
||||
|
||||
// Ensure settings are loaded - but only after auth is fully ready
|
||||
// This reads cached update info from backend (updated by scheduler)
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
@@ -29,31 +30,20 @@ export const UpdateNotificationProvider = ({ children }) => {
|
||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||
});
|
||||
|
||||
// Memoize the enabled condition to prevent unnecessary re-evaluations
|
||||
const isQueryEnabled = useMemo(() => {
|
||||
return (
|
||||
isAuthReady(authPhase, isAuthenticated()) &&
|
||||
!!settings &&
|
||||
!settingsLoading
|
||||
);
|
||||
}, [authPhase, isAuthenticated, settings, settingsLoading]);
|
||||
// Read cached update information from settings (no GitHub API calls)
|
||||
// The backend scheduler updates this data periodically
|
||||
const updateAvailable = settings?.is_update_available && !dismissed;
|
||||
const updateInfo = settings
|
||||
? {
|
||||
isUpdateAvailable: settings.is_update_available,
|
||||
latestVersion: settings.latest_version,
|
||||
currentVersion: settings.current_version,
|
||||
last_update_check: settings.last_update_check,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Query for update information
|
||||
const {
|
||||
data: updateData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["updateCheck"],
|
||||
queryFn: () => versionAPI.checkUpdates().then((res) => res.data),
|
||||
staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
retry: 1,
|
||||
enabled: isQueryEnabled,
|
||||
});
|
||||
|
||||
const updateAvailable = updateData?.isUpdateAvailable && !dismissed;
|
||||
const updateInfo = updateData;
|
||||
const isLoading = settingsLoading;
|
||||
const error = null;
|
||||
|
||||
const dismissNotification = () => {
|
||||
setDismissed(true);
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Chart as ChartJS,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "chart.js";
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bar, Doughnut, Pie } from "react-chartjs-2";
|
||||
import { Bar, Doughnut, Line, Pie } from "react-chartjs-2";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DashboardSettingsModal from "../components/DashboardSettingsModal";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
@@ -43,12 +45,16 @@ ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
);
|
||||
|
||||
const Dashboard = () => {
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [cardPreferences, setCardPreferences] = useState([]);
|
||||
const [packageTrendsPeriod, setPackageTrendsPeriod] = useState("1"); // days
|
||||
const [packageTrendsHost, setPackageTrendsHost] = useState("all"); // host filter
|
||||
const navigate = useNavigate();
|
||||
const { isDark } = useTheme();
|
||||
const { user } = useAuth();
|
||||
@@ -91,7 +97,7 @@ const Dashboard = () => {
|
||||
navigate("/repositories");
|
||||
};
|
||||
|
||||
const handleOSDistributionClick = () => {
|
||||
const _handleOSDistributionClick = () => {
|
||||
navigate("/hosts?showFilters=true", { replace: true });
|
||||
};
|
||||
|
||||
@@ -99,7 +105,7 @@ const Dashboard = () => {
|
||||
navigate("/hosts?filter=needsUpdates", { replace: true });
|
||||
};
|
||||
|
||||
const handlePackagePriorityClick = () => {
|
||||
const _handlePackagePriorityClick = () => {
|
||||
navigate("/packages?filter=security");
|
||||
};
|
||||
|
||||
@@ -144,8 +150,8 @@ const Dashboard = () => {
|
||||
// Map priority names to filter parameters
|
||||
if (priorityName.toLowerCase().includes("security")) {
|
||||
navigate("/packages?filter=security", { replace: true });
|
||||
} else if (priorityName.toLowerCase().includes("outdated")) {
|
||||
navigate("/packages?filter=outdated", { replace: true });
|
||||
} else if (priorityName.toLowerCase().includes("regular")) {
|
||||
navigate("/packages?filter=regular", { replace: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -189,6 +195,26 @@ const Dashboard = () => {
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
});
|
||||
|
||||
// Package trends data query
|
||||
const {
|
||||
data: packageTrendsData,
|
||||
isLoading: packageTrendsLoading,
|
||||
error: _packageTrendsError,
|
||||
} = useQuery({
|
||||
queryKey: ["packageTrends", packageTrendsPeriod, packageTrendsHost],
|
||||
queryFn: () => {
|
||||
const params = {
|
||||
days: packageTrendsPeriod,
|
||||
};
|
||||
if (packageTrendsHost !== "all") {
|
||||
params.hostId = packageTrendsHost;
|
||||
}
|
||||
return dashboardAPI.getPackageTrends(params).then((res) => res.data);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Fetch recent users (permission protected server-side)
|
||||
const { data: recentUsers } = useQuery({
|
||||
queryKey: ["dashboardRecentUsers"],
|
||||
@@ -299,6 +325,8 @@ const Dashboard = () => {
|
||||
].includes(cardId)
|
||||
) {
|
||||
return "charts";
|
||||
} else if (["packageTrends"].includes(cardId)) {
|
||||
return "charts";
|
||||
} else if (["erroredHosts", "quickStats"].includes(cardId)) {
|
||||
return "fullwidth";
|
||||
}
|
||||
@@ -312,6 +340,8 @@ const Dashboard = () => {
|
||||
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4";
|
||||
case "charts":
|
||||
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
|
||||
case "widecharts":
|
||||
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
|
||||
case "fullwidth":
|
||||
return "space-y-6";
|
||||
default:
|
||||
@@ -651,17 +681,7 @@ const Dashboard = () => {
|
||||
|
||||
case "osDistribution":
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
onClick={handleOSDistributionClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleOSDistributionClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card p-6 w-full">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
OS Distribution
|
||||
</h3>
|
||||
@@ -670,22 +690,12 @@ const Dashboard = () => {
|
||||
<Pie data={osChartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "osDistributionDoughnut":
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
onClick={handleOSDistributionClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleOSDistributionClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card p-6 w-full">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
OS Distribution
|
||||
</h3>
|
||||
@@ -694,29 +704,19 @@ const Dashboard = () => {
|
||||
<Doughnut data={osChartData} options={doughnutChartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "osDistributionBar":
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
onClick={handleOSDistributionClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleOSDistributionClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card p-6 w-full">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
OS Distribution
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<Bar data={osBarChartData} options={barChartOptions} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "updateStatus":
|
||||
@@ -748,19 +748,9 @@ const Dashboard = () => {
|
||||
|
||||
case "packagePriority":
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||
onClick={handlePackagePriorityClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handlePackagePriorityClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card p-6 w-full">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Package Priority
|
||||
Outdated Packages by Priority
|
||||
</h3>
|
||||
<div className="h-64 w-full flex items-center justify-center">
|
||||
<div className="w-full h-full max-w-sm">
|
||||
@@ -770,7 +760,72 @@ const Dashboard = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "packageTrends":
|
||||
return (
|
||||
<div className="card p-6 w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Package Trends Over Time
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Period Selector */}
|
||||
<select
|
||||
value={packageTrendsPeriod}
|
||||
onChange={(e) => setPackageTrendsPeriod(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="1">Last 24 hours</option>
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
<option value="180">Last 6 months</option>
|
||||
<option value="365">Last year</option>
|
||||
</select>
|
||||
|
||||
{/* Host Selector */}
|
||||
<select
|
||||
value={packageTrendsHost}
|
||||
onChange={(e) => setPackageTrendsHost(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="all">All Hosts</option>
|
||||
{packageTrendsData?.hosts?.length > 0 ? (
|
||||
packageTrendsData.hosts.map((host) => (
|
||||
<option key={host.id} value={host.id}>
|
||||
{host.friendly_name || host.hostname}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option disabled>
|
||||
{packageTrendsLoading
|
||||
? "Loading hosts..."
|
||||
: "No hosts available"}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-64 w-full">
|
||||
{packageTrendsLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : packageTrendsData?.chartData ? (
|
||||
<Line
|
||||
data={packageTrendsData.chartData}
|
||||
options={packageTrendsChartOptions}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-secondary-500 dark:text-secondary-400">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "quickStats": {
|
||||
@@ -1068,6 +1123,167 @@ const Dashboard = () => {
|
||||
onClick: handlePackagePriorityChartClick,
|
||||
};
|
||||
|
||||
const packageTrendsChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "top",
|
||||
labels: {
|
||||
color: isDark ? "#ffffff" : "#374151",
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
pointStyle: "circle",
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
backgroundColor: isDark ? "#374151" : "#ffffff",
|
||||
titleColor: isDark ? "#ffffff" : "#374151",
|
||||
bodyColor: isDark ? "#ffffff" : "#374151",
|
||||
borderColor: isDark ? "#4B5563" : "#E5E7EB",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const label = context[0].label;
|
||||
|
||||
// Handle empty or invalid labels
|
||||
if (!label || typeof label !== "string") {
|
||||
return "Unknown Date";
|
||||
}
|
||||
|
||||
// Format hourly labels (e.g., "2025-10-07T14" -> "Oct 7, 2:00 PM")
|
||||
if (label.includes("T")) {
|
||||
try {
|
||||
const date = new Date(`${label}:00:00`);
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return label; // Return original label if date is invalid
|
||||
}
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
} catch (error) {
|
||||
return label; // Return original label if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
|
||||
try {
|
||||
const date = new Date(label);
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return label; // Return original label if date is invalid
|
||||
}
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch (error) {
|
||||
return label; // Return original label if parsing fails
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: packageTrendsPeriod === "1" ? "Time (Hours)" : "Date",
|
||||
color: isDark ? "#ffffff" : "#374151",
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? "#ffffff" : "#374151",
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: function (value, _index, _ticks) {
|
||||
const label = this.getLabelForValue(value);
|
||||
|
||||
// Handle empty or invalid labels
|
||||
if (!label || typeof label !== "string") {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// Format hourly labels (e.g., "2025-10-07T14" -> "2 PM")
|
||||
if (label.includes("T")) {
|
||||
try {
|
||||
const hour = label.split("T")[1];
|
||||
const hourNum = parseInt(hour, 10);
|
||||
|
||||
// Validate hour number
|
||||
if (isNaN(hourNum) || hourNum < 0 || hourNum > 23) {
|
||||
return hour; // Return original hour if invalid
|
||||
}
|
||||
|
||||
return hourNum === 0
|
||||
? "12 AM"
|
||||
: hourNum < 12
|
||||
? `${hourNum} AM`
|
||||
: hourNum === 12
|
||||
? "12 PM"
|
||||
: `${hourNum - 12} PM`;
|
||||
} catch (error) {
|
||||
return label; // Return original label if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
|
||||
try {
|
||||
const date = new Date(label);
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return label; // Return original label if date is invalid
|
||||
}
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch (error) {
|
||||
return label; // Return original label if parsing fails
|
||||
}
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: isDark ? "#374151" : "#E5E7EB",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: "Number of Packages",
|
||||
color: isDark ? "#ffffff" : "#374151",
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? "#ffffff" : "#374151",
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
grid: {
|
||||
color: isDark ? "#374151" : "#E5E7EB",
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: "nearest",
|
||||
axis: "x",
|
||||
intersect: false,
|
||||
},
|
||||
};
|
||||
|
||||
const barChartOptions = {
|
||||
responsive: true,
|
||||
indexAxis: "y", // Make the chart horizontal
|
||||
@@ -1100,6 +1316,7 @@ const Dashboard = () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
onClick: handleOSChartClick,
|
||||
};
|
||||
|
||||
const osChartData = {
|
||||
@@ -1245,7 +1462,12 @@ const Dashboard = () => {
|
||||
className={getGroupClassName(group.type)}
|
||||
>
|
||||
{group.cards.map((card, cardIndex) => (
|
||||
<div key={`card-${card.cardId}-${groupIndex}-${cardIndex}`}>
|
||||
<div
|
||||
key={`card-${card.cardId}-${groupIndex}-${cardIndex}`}
|
||||
className={
|
||||
card.cardId === "packageTrends" ? "lg:col-span-2" : ""
|
||||
}
|
||||
>
|
||||
{renderCard(card.cardId)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -657,6 +657,18 @@ const Hosts = () => {
|
||||
hideStale,
|
||||
]);
|
||||
|
||||
// Get unique OS types from hosts for dynamic dropdown
|
||||
const uniqueOsTypes = useMemo(() => {
|
||||
if (!hosts) return [];
|
||||
const osTypes = new Set();
|
||||
hosts.forEach((host) => {
|
||||
if (host.os_type) {
|
||||
osTypes.add(host.os_type);
|
||||
}
|
||||
});
|
||||
return Array.from(osTypes).sort();
|
||||
}, [hosts]);
|
||||
|
||||
// Group hosts by selected field
|
||||
const groupedHosts = useMemo(() => {
|
||||
if (groupBy === "none") {
|
||||
@@ -870,9 +882,11 @@ const Hosts = () => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/packages?host=${host.id}`)}
|
||||
onClick={() =>
|
||||
navigate(`/packages?host=${host.id}&filter=outdated`)
|
||||
}
|
||||
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium hover:underline"
|
||||
title="View packages for this host"
|
||||
title="View outdated packages for this host"
|
||||
>
|
||||
{host.updatesCount || 0}
|
||||
</button>
|
||||
@@ -1266,9 +1280,11 @@ const Hosts = () => {
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All OS</option>
|
||||
<option value="linux">Linux</option>
|
||||
<option value="windows">Windows</option>
|
||||
<option value="macos">macOS</option>
|
||||
{uniqueOsTypes.map((osType) => (
|
||||
<option key={osType} value={osType.toLowerCase()}>
|
||||
{osType}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
@@ -1554,6 +1570,7 @@ const BulkAssignModal = ({
|
||||
isLoading,
|
||||
}) => {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
const bulkHostGroupId = useId();
|
||||
|
||||
// Fetch host groups for selection
|
||||
const { data: hostGroups } = useQuery({
|
||||
@@ -1572,28 +1589,31 @@ const BulkAssignModal = ({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Assign to Host Group
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600"
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-300 dark:hover:text-secondary-100"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-secondary-600 mb-2">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
Assigning {selectedHosts.length} host
|
||||
{selectedHosts.length !== 1 ? "s" : ""}:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto bg-secondary-50 rounded-md p-3">
|
||||
<div className="max-h-32 overflow-y-auto bg-secondary-50 dark:bg-secondary-700 rounded-md p-3">
|
||||
{selectedHostNames.map((friendlyName) => (
|
||||
<div key={friendlyName} className="text-sm text-secondary-700">
|
||||
<div
|
||||
key={friendlyName}
|
||||
className="text-sm text-secondary-700 dark:text-secondary-300"
|
||||
>
|
||||
• {friendlyName}
|
||||
</div>
|
||||
))}
|
||||
@@ -1604,7 +1624,7 @@ const BulkAssignModal = ({
|
||||
<div>
|
||||
<label
|
||||
htmlFor={bulkHostGroupId}
|
||||
className="block text-sm font-medium text-secondary-700 mb-1"
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"
|
||||
>
|
||||
Host Group
|
||||
</label>
|
||||
@@ -1612,7 +1632,7 @@ const BulkAssignModal = ({
|
||||
id={bulkHostGroupId}
|
||||
value={selectedGroupId}
|
||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">No group (ungrouped)</option>
|
||||
{hostGroups?.map((group) => (
|
||||
@@ -1621,7 +1641,7 @@ const BulkAssignModal = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-secondary-500">
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Select a group to assign these hosts to, or leave ungrouped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ const Login = () => {
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const tokenId = useId();
|
||||
const rememberMeId = useId();
|
||||
const { login, setAuthState } = useAuth();
|
||||
const [isSignupMode, setIsSignupMode] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -33,6 +34,7 @@ const Login = () => {
|
||||
});
|
||||
const [tfaData, setTfaData] = useState({
|
||||
token: "",
|
||||
remember_me: false,
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -127,7 +129,11 @@ const Login = () => {
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
|
||||
const response = await authAPI.verifyTfa(
|
||||
tfaUsername,
|
||||
tfaData.token,
|
||||
tfaData.remember_me,
|
||||
);
|
||||
|
||||
if (response.data?.token) {
|
||||
// Update AuthContext with the new authentication state
|
||||
@@ -158,9 +164,11 @@ const Login = () => {
|
||||
};
|
||||
|
||||
const handleTfaInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setTfaData({
|
||||
...tfaData,
|
||||
[e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
|
||||
[name]:
|
||||
type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6),
|
||||
});
|
||||
// Clear error when user starts typing
|
||||
if (error) {
|
||||
@@ -170,7 +178,7 @@ const Login = () => {
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setRequiresTfa(false);
|
||||
setTfaData({ token: "" });
|
||||
setTfaData({ token: "", remember_me: false });
|
||||
setError("");
|
||||
};
|
||||
|
||||
@@ -436,6 +444,23 @@ const Login = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={rememberMeId}
|
||||
name="remember_me"
|
||||
type="checkbox"
|
||||
checked={tfaData.remember_me}
|
||||
onChange={handleTfaInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor={rememberMeId}
|
||||
className="ml-2 block text-sm text-secondary-700"
|
||||
>
|
||||
Remember me on this computer (skip TFA for 30 days)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
|
||||
@@ -1,23 +1,476 @@
|
||||
import { Package } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
ChartColumnBig,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { formatRelativeTime, packagesAPI } from "../utils/api";
|
||||
|
||||
const PackageDetail = () => {
|
||||
const { packageId } = useParams();
|
||||
const decodedPackageId = decodeURIComponent(packageId || "");
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
|
||||
// Fetch package details
|
||||
const {
|
||||
data: packageData,
|
||||
isLoading: isLoadingPackage,
|
||||
error: packageError,
|
||||
refetch: refetchPackage,
|
||||
} = useQuery({
|
||||
queryKey: ["package", decodedPackageId],
|
||||
queryFn: () =>
|
||||
packagesAPI.getById(decodedPackageId).then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!decodedPackageId,
|
||||
});
|
||||
|
||||
// Fetch hosts that have this package
|
||||
const {
|
||||
data: hostsData,
|
||||
isLoading: isLoadingHosts,
|
||||
error: hostsError,
|
||||
refetch: refetchHosts,
|
||||
} = useQuery({
|
||||
queryKey: ["package-hosts", decodedPackageId, searchTerm],
|
||||
queryFn: () =>
|
||||
packagesAPI
|
||||
.getHosts(decodedPackageId, { search: searchTerm, limit: 1000 })
|
||||
.then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!decodedPackageId,
|
||||
});
|
||||
|
||||
const hosts = hostsData?.hosts || [];
|
||||
|
||||
// Filter and paginate hosts
|
||||
const filteredAndPaginatedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = hosts.filter(
|
||||
(host) =>
|
||||
host.friendlyName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return filtered.slice(startIndex, endIndex);
|
||||
}, [hosts, searchTerm, currentPage, pageSize]);
|
||||
|
||||
const totalPages = Math.ceil(
|
||||
(searchTerm
|
||||
? hosts.filter(
|
||||
(host) =>
|
||||
host.friendlyName
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
host.hostname?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
).length
|
||||
: hosts.length) / pageSize,
|
||||
);
|
||||
|
||||
const handleHostClick = (hostId) => {
|
||||
navigate(`/hosts/${hostId}`);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchPackage();
|
||||
refetchHosts();
|
||||
};
|
||||
|
||||
if (isLoadingPackage) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (packageError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading package
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{packageError.message || "Failed to load package details"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchPackage()}
|
||||
className="mt-2 btn-danger text-xs"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!packageData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
Package not found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pkg = packageData;
|
||||
const stats = packageData.stats || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="card p-8 text-center">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 mb-2">
|
||||
Package Details
|
||||
</h3>
|
||||
<p className="text-secondary-600">
|
||||
Detailed view for package: {packageId}
|
||||
</p>
|
||||
<p className="text-secondary-600 mt-2">
|
||||
This page will show package information, affected hosts, version
|
||||
distribution, and more.
|
||||
</p>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/packages")}
|
||||
className="flex items-center gap-2 text-secondary-600 hover:text-secondary-900 dark:text-secondary-400 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Packages
|
||||
</button>
|
||||
<ChevronRight className="h-4 w-4 text-secondary-400" />
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
{pkg.name}
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoadingPackage || isLoadingHosts}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${
|
||||
isLoadingPackage || isLoadingHosts ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Package Overview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Package Info */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<Package className="h-8 w-8 text-primary-600 flex-shrink-0 mt-1" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
|
||||
{pkg.name}
|
||||
</h2>
|
||||
{pkg.description && (
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
{pkg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{pkg.category && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Category: {pkg.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pkg.latest_version && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Latest: {pkg.latest_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pkg.updated_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-secondary-400" />
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Updated: {formatRelativeTime(pkg.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mb-4">
|
||||
{stats.updatesNeeded > 0 ? (
|
||||
stats.securityUpdates > 0 ? (
|
||||
<span className="badge-danger flex items-center gap-1 w-fit">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning w-fit">Update Available</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-success w-fit">Up to Date</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ChartColumnBig className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="font-medium text-secondary-900 dark:text-white">
|
||||
Installation Stats
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Total Installations
|
||||
</span>
|
||||
<span className="font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.totalInstalls || 0}
|
||||
</span>
|
||||
</div>
|
||||
{stats.updatesNeeded > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Hosts Needing Updates
|
||||
</span>
|
||||
<span className="font-semibold text-warning-600">
|
||||
{stats.updatesNeeded}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.securityUpdates > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Security Updates
|
||||
</span>
|
||||
<span className="font-semibold text-danger-600">
|
||||
{stats.securityUpdates}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-secondary-600 dark:text-secondary-300">
|
||||
Up to Date
|
||||
</span>
|
||||
<span className="font-semibold text-success-600">
|
||||
{(stats.totalInstalls || 0) - (stats.updatesNeeded || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hosts List */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Installed On Hosts ({hosts.length})
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search hosts..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{isLoadingHosts ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : hostsError ? (
|
||||
<div className="p-6">
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading hosts
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{hostsError.message || "Failed to load hosts"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredAndPaginatedHosts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
{searchTerm
|
||||
? "No hosts match your search"
|
||||
: "No hosts have this package installed"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Host
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Current Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Last Updated
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndPaginatedHosts.map((host) => (
|
||||
<tr
|
||||
key={host.hostId}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
|
||||
onClick={() => handleHostClick(host.hostId)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{host.friendlyName || host.hostname}
|
||||
</div>
|
||||
{host.friendlyName && host.hostname && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{host.hostname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{host.currentVersion || "Unknown"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{host.needsUpdate ? (
|
||||
host.isSecurityUpdate ? (
|
||||
<span className="badge-danger flex items-center gap-1 w-fit">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning w-fit">
|
||||
Update Available
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-success w-fit">
|
||||
Up to Date
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{host.lastUpdate
|
||||
? formatRelativeTime(host.lastUpdate)
|
||||
: "Never"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Rows per page:
|
||||
</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Columns,
|
||||
Eye as EyeIcon,
|
||||
EyeOff as EyeOffIcon,
|
||||
@@ -17,16 +19,28 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { dashboardAPI } from "../utils/api";
|
||||
import { dashboardAPI, packagesAPI } from "../utils/api";
|
||||
|
||||
const Packages = () => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [securityFilter, setSecurityFilter] = useState("all");
|
||||
const [updateStatusFilter, setUpdateStatusFilter] = useState("all-packages");
|
||||
const [hostFilter, setHostFilter] = useState("all");
|
||||
const [sortField, setSortField] = useState("name");
|
||||
const [sortDirection, setSortDirection] = useState("asc");
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const saved = localStorage.getItem("packages-page-size");
|
||||
if (saved) {
|
||||
const parsedSize = parseInt(saved, 10);
|
||||
// Validate that the saved page size is one of the allowed values
|
||||
if ([25, 50, 100, 200].includes(parsedSize)) {
|
||||
return parsedSize;
|
||||
}
|
||||
}
|
||||
return 25; // Default fallback
|
||||
});
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -42,8 +56,8 @@ const Packages = () => {
|
||||
const [columnConfig, setColumnConfig] = useState(() => {
|
||||
const defaultConfig = [
|
||||
{ id: "name", label: "Package", visible: true, order: 0 },
|
||||
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
|
||||
{ id: "priority", label: "Priority", visible: true, order: 2 },
|
||||
{ id: "packageHosts", label: "Installed On", visible: true, order: 1 },
|
||||
{ id: "status", label: "Status", visible: true, order: 2 },
|
||||
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
|
||||
];
|
||||
|
||||
@@ -65,10 +79,10 @@ const Packages = () => {
|
||||
localStorage.setItem("packages-column-config", JSON.stringify(newConfig));
|
||||
};
|
||||
|
||||
// Handle affected hosts click
|
||||
const handleAffectedHostsClick = (pkg) => {
|
||||
const affectedHosts = pkg.affectedHosts || [];
|
||||
const hostIds = affectedHosts.map((host) => host.hostId);
|
||||
// Handle hosts click (view hosts where package is installed)
|
||||
const handlePackageHostsClick = (pkg) => {
|
||||
const packageHosts = pkg.packageHosts || [];
|
||||
const hostIds = packageHosts.map((host) => host.hostId);
|
||||
|
||||
// Create URL with selected hosts and filter
|
||||
const params = new URLSearchParams();
|
||||
@@ -86,27 +100,59 @@ const Packages = () => {
|
||||
// For outdated packages, we want to show all packages that need updates
|
||||
// This is the default behavior, so we don't need to change filters
|
||||
setCategoryFilter("all");
|
||||
setSecurityFilter("all");
|
||||
setUpdateStatusFilter("needs-updates");
|
||||
} else if (filter === "security") {
|
||||
// For security updates, filter to show only security updates
|
||||
setSecurityFilter("security");
|
||||
setUpdateStatusFilter("security-updates");
|
||||
setCategoryFilter("all");
|
||||
} else if (filter === "regular") {
|
||||
// For regular (non-security) updates
|
||||
setUpdateStatusFilter("regular-updates");
|
||||
setCategoryFilter("all");
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const {
|
||||
data: packages,
|
||||
data: packagesResponse,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["packages"],
|
||||
queryFn: () => dashboardAPI.getPackages().then((res) => res.data),
|
||||
queryKey: ["packages", hostFilter, updateStatusFilter],
|
||||
queryFn: () => {
|
||||
const params = { limit: 10000 }; // High limit to effectively get all packages
|
||||
if (hostFilter && hostFilter !== "all") {
|
||||
params.host = hostFilter;
|
||||
}
|
||||
// Pass update status filter to backend to pre-filter packages
|
||||
if (updateStatusFilter === "needs-updates") {
|
||||
params.needsUpdate = "true";
|
||||
} else if (updateStatusFilter === "security-updates") {
|
||||
params.isSecurityUpdate = "true";
|
||||
}
|
||||
return packagesAPI.getAll(params).then((res) => res.data);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
});
|
||||
|
||||
// Extract packages from the response and normalise the data structure
|
||||
const packages = useMemo(() => {
|
||||
if (!packagesResponse?.packages) return [];
|
||||
|
||||
return packagesResponse.packages.map((pkg) => ({
|
||||
...pkg,
|
||||
// Normalise field names to match the frontend expectations
|
||||
packageHostsCount: pkg.packageHostsCount || pkg.stats?.totalInstalls || 0,
|
||||
latestVersion: pkg.latest_version || pkg.latestVersion || "Unknown",
|
||||
isUpdatable: (pkg.stats?.updatesNeeded || 0) > 0,
|
||||
isSecurityUpdate: (pkg.stats?.securityUpdates || 0) > 0,
|
||||
// Ensure we have hosts array (for packages, this contains all hosts where the package is installed)
|
||||
packageHosts: pkg.packageHosts || [],
|
||||
}));
|
||||
}, [packagesResponse]);
|
||||
|
||||
// Fetch hosts data to get total packages count
|
||||
const { data: hosts } = useQuery({
|
||||
queryKey: ["hosts"],
|
||||
@@ -128,17 +174,24 @@ const Packages = () => {
|
||||
const matchesCategory =
|
||||
categoryFilter === "all" || pkg.category === categoryFilter;
|
||||
|
||||
const matchesSecurity =
|
||||
securityFilter === "all" ||
|
||||
(securityFilter === "security" && pkg.isSecurityUpdate) ||
|
||||
(securityFilter === "regular" && !pkg.isSecurityUpdate);
|
||||
const matchesUpdateStatus =
|
||||
updateStatusFilter === "all-packages" ||
|
||||
(updateStatusFilter === "needs-updates" &&
|
||||
(pkg.stats?.updatesNeeded || 0) > 0) ||
|
||||
(updateStatusFilter === "security-updates" &&
|
||||
(pkg.stats?.securityUpdates || 0) > 0) ||
|
||||
(updateStatusFilter === "regular-updates" &&
|
||||
(pkg.stats?.updatesNeeded || 0) > 0 &&
|
||||
(pkg.stats?.securityUpdates || 0) === 0);
|
||||
|
||||
const affectedHosts = pkg.affectedHosts || [];
|
||||
const packageHosts = pkg.packageHosts || [];
|
||||
const matchesHost =
|
||||
hostFilter === "all" ||
|
||||
affectedHosts.some((host) => host.hostId === hostFilter);
|
||||
packageHosts.some((host) => host.hostId === hostFilter);
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSecurity && matchesHost;
|
||||
return (
|
||||
matchesSearch && matchesCategory && matchesUpdateStatus && matchesHost
|
||||
);
|
||||
});
|
||||
|
||||
// Sorting
|
||||
@@ -154,14 +207,38 @@ const Packages = () => {
|
||||
aValue = a.latestVersion?.toLowerCase() || "";
|
||||
bValue = b.latestVersion?.toLowerCase() || "";
|
||||
break;
|
||||
case "affectedHosts":
|
||||
aValue = a.affectedHostsCount || a.affectedHosts?.length || 0;
|
||||
bValue = b.affectedHostsCount || b.affectedHosts?.length || 0;
|
||||
case "packageHosts":
|
||||
aValue = a.packageHostsCount || a.packageHosts?.length || 0;
|
||||
bValue = b.packageHostsCount || b.packageHosts?.length || 0;
|
||||
break;
|
||||
case "priority":
|
||||
aValue = a.isSecurityUpdate ? 0 : 1; // Security updates first
|
||||
bValue = b.isSecurityUpdate ? 0 : 1;
|
||||
case "status": {
|
||||
// Handle sorting for the three status states: Up to Date, Update Available, Security Update Available
|
||||
const aNeedsUpdates = (a.stats?.updatesNeeded || 0) > 0;
|
||||
const bNeedsUpdates = (b.stats?.updatesNeeded || 0) > 0;
|
||||
|
||||
// Define priority order: Security Update (0) > Regular Update (1) > Up to Date (2)
|
||||
let aPriority, bPriority;
|
||||
|
||||
if (!aNeedsUpdates) {
|
||||
aPriority = 2; // Up to Date
|
||||
} else if (a.isSecurityUpdate) {
|
||||
aPriority = 0; // Security Update
|
||||
} else {
|
||||
aPriority = 1; // Regular Update
|
||||
}
|
||||
|
||||
if (!bNeedsUpdates) {
|
||||
bPriority = 2; // Up to Date
|
||||
} else if (b.isSecurityUpdate) {
|
||||
bPriority = 0; // Security Update
|
||||
} else {
|
||||
bPriority = 1; // Regular Update
|
||||
}
|
||||
|
||||
aValue = aPriority;
|
||||
bValue = bPriority;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
aValue = a.name?.toLowerCase() || "";
|
||||
bValue = b.name?.toLowerCase() || "";
|
||||
@@ -177,12 +254,33 @@ const Packages = () => {
|
||||
packages,
|
||||
searchTerm,
|
||||
categoryFilter,
|
||||
securityFilter,
|
||||
updateStatusFilter,
|
||||
sortField,
|
||||
sortDirection,
|
||||
hostFilter,
|
||||
]);
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(filteredAndSortedPackages.length / pageSize);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedPackages = filteredAndSortedPackages.slice(
|
||||
startIndex,
|
||||
endIndex,
|
||||
);
|
||||
|
||||
// Reset to first page when filters or page size change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: We want this effect to run when filter values or page size change to reset pagination
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, categoryFilter, updateStatusFilter, hostFilter, pageSize]);
|
||||
|
||||
// Function to handle page size change and save to localStorage
|
||||
const handlePageSizeChange = (newPageSize) => {
|
||||
setPageSize(newPageSize);
|
||||
localStorage.setItem("packages-page-size", newPageSize.toString());
|
||||
};
|
||||
|
||||
// Get visible columns in order
|
||||
const visibleColumns = columnConfig
|
||||
.filter((col) => col.visible)
|
||||
@@ -231,8 +329,8 @@ const Packages = () => {
|
||||
const resetColumns = () => {
|
||||
const defaultConfig = [
|
||||
{ id: "name", label: "Package", visible: true, order: 0 },
|
||||
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
|
||||
{ id: "priority", label: "Priority", visible: true, order: 2 },
|
||||
{ id: "packageHosts", label: "Installed On", visible: true, order: 1 },
|
||||
{ id: "status", label: "Status", visible: true, order: 2 },
|
||||
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
|
||||
];
|
||||
updateColumnConfig(defaultConfig);
|
||||
@@ -243,10 +341,14 @@ const Packages = () => {
|
||||
switch (column.id) {
|
||||
case "name":
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/packages/${pkg.id}`)}
|
||||
className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group w-full"
|
||||
>
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
||||
{pkg.name}
|
||||
</div>
|
||||
{pkg.description && (
|
||||
@@ -260,33 +362,58 @@ const Packages = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
case "affectedHosts": {
|
||||
const affectedHostsCount =
|
||||
pkg.affectedHostsCount || pkg.affectedHosts?.length || 0;
|
||||
case "packageHosts": {
|
||||
// Show total number of hosts where this package is installed
|
||||
const installedHostsCount =
|
||||
pkg.packageHostsCount ||
|
||||
pkg.stats?.totalInstalls ||
|
||||
pkg.packageHosts?.length ||
|
||||
0;
|
||||
// For packages that need updates, show how many need updates
|
||||
const hostsNeedingUpdates = pkg.stats?.updatesNeeded || 0;
|
||||
|
||||
const displayText =
|
||||
hostsNeedingUpdates > 0 && hostsNeedingUpdates < installedHostsCount
|
||||
? `${hostsNeedingUpdates}/${installedHostsCount} hosts`
|
||||
: `${installedHostsCount} host${installedHostsCount !== 1 ? "s" : ""}`;
|
||||
|
||||
const titleText =
|
||||
hostsNeedingUpdates > 0 && hostsNeedingUpdates < installedHostsCount
|
||||
? `${hostsNeedingUpdates} of ${installedHostsCount} hosts need updates`
|
||||
: `Installed on ${installedHostsCount} host${installedHostsCount !== 1 ? "s" : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAffectedHostsClick(pkg)}
|
||||
onClick={() => handlePackageHostsClick(pkg)}
|
||||
className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
|
||||
title={`Click to view all ${affectedHostsCount} affected hosts`}
|
||||
title={titleText}
|
||||
>
|
||||
<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
||||
{affectedHostsCount} host{affectedHostsCount !== 1 ? "s" : ""}
|
||||
{displayText}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
case "priority":
|
||||
case "status": {
|
||||
// Check if this package needs updates
|
||||
const needsUpdates = (pkg.stats?.updatesNeeded || 0) > 0;
|
||||
|
||||
if (!needsUpdates) {
|
||||
return <span className="badge-success">Up to Date</span>;
|
||||
}
|
||||
|
||||
return pkg.isSecurityUpdate ? (
|
||||
<span className="badge-danger flex items-center gap-1">
|
||||
<span className="badge-danger">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update
|
||||
Security Update Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning">Regular Update</span>
|
||||
<span className="badge-warning">Update Available</span>
|
||||
);
|
||||
}
|
||||
case "latestVersion":
|
||||
return (
|
||||
<div
|
||||
@@ -305,28 +432,38 @@ const Packages = () => {
|
||||
const categories =
|
||||
[...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || [];
|
||||
|
||||
// Calculate unique affected hosts
|
||||
const uniqueAffectedHosts = new Set();
|
||||
// Calculate unique package hosts
|
||||
const uniquePackageHosts = new Set();
|
||||
packages?.forEach((pkg) => {
|
||||
const affectedHosts = pkg.affectedHosts || [];
|
||||
affectedHosts.forEach((host) => {
|
||||
uniqueAffectedHosts.add(host.hostId);
|
||||
});
|
||||
// Only count hosts for packages that need updates
|
||||
if ((pkg.stats?.updatesNeeded || 0) > 0) {
|
||||
const packageHosts = pkg.packageHosts || [];
|
||||
packageHosts.forEach((host) => {
|
||||
uniquePackageHosts.add(host.hostId);
|
||||
});
|
||||
}
|
||||
});
|
||||
const uniqueAffectedHostsCount = uniqueAffectedHosts.size;
|
||||
const uniquePackageHostsCount = uniquePackageHosts.size;
|
||||
|
||||
// Calculate total packages across all hosts (including up-to-date ones)
|
||||
// Calculate total packages installed
|
||||
// When filtering by host, count each package once (since it can only be installed once per host)
|
||||
// When not filtering, sum up all installations across all hosts
|
||||
const totalPackagesCount =
|
||||
hosts?.reduce((total, host) => {
|
||||
return total + (host.totalPackagesCount || 0);
|
||||
}, 0) || 0;
|
||||
hostFilter && hostFilter !== "all"
|
||||
? packages?.length || 0
|
||||
: packages?.reduce(
|
||||
(sum, pkg) => sum + (pkg.stats?.totalInstalls || 0),
|
||||
0,
|
||||
) || 0;
|
||||
|
||||
// Calculate outdated packages (packages that need updates)
|
||||
const outdatedPackagesCount = packages?.length || 0;
|
||||
// Calculate outdated packages
|
||||
const outdatedPackagesCount =
|
||||
packages?.filter((pkg) => (pkg.stats?.updatesNeeded || 0) > 0).length || 0;
|
||||
|
||||
// Calculate security updates
|
||||
const securityUpdatesCount =
|
||||
packages?.filter((pkg) => pkg.isSecurityUpdate).length || 0;
|
||||
packages?.filter((pkg) => (pkg.stats?.securityUpdates || 0) > 0).length ||
|
||||
0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -398,7 +535,7 @@ const Packages = () => {
|
||||
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Total Packages
|
||||
Total Installed
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{totalPackagesCount}
|
||||
@@ -429,7 +566,7 @@ const Packages = () => {
|
||||
Hosts Pending Updates
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{uniqueAffectedHostsCount}
|
||||
{uniquePackageHostsCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -490,16 +627,21 @@ const Packages = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Security Filter */}
|
||||
{/* Update Status Filter */}
|
||||
<div className="sm:w-48">
|
||||
<select
|
||||
value={securityFilter}
|
||||
onChange={(e) => setSecurityFilter(e.target.value)}
|
||||
value={updateStatusFilter}
|
||||
onChange={(e) => setUpdateStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Updates</option>
|
||||
<option value="security">Security Only</option>
|
||||
<option value="regular">Regular Only</option>
|
||||
<option value="all-packages">All Packages</option>
|
||||
<option value="needs-updates">
|
||||
Packages Needing Updates
|
||||
</option>
|
||||
<option value="security-updates">
|
||||
Security Updates Only
|
||||
</option>
|
||||
<option value="regular-updates">Regular Updates Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -539,12 +681,13 @@ const Packages = () => {
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
{packages?.length === 0
|
||||
? "No packages need updates"
|
||||
? "No packages found"
|
||||
: "No packages match your filters"}
|
||||
</p>
|
||||
{packages?.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
All packages are up to date across all hosts
|
||||
Packages will appear here once hosts start reporting their
|
||||
installed packages
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -571,7 +714,7 @@ const Packages = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndSortedPackages.map((pkg) => (
|
||||
{paginatedPackages.map((pkg) => (
|
||||
<tr
|
||||
key={pkg.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
@@ -591,6 +734,57 @@ const Packages = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredAndSortedPackages.length > 0 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Rows per page:
|
||||
</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) =>
|
||||
handlePageSizeChange(Number(e.target.value))
|
||||
}
|
||||
className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
</div>
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
{startIndex + 1}-
|
||||
{Math.min(endIndex, filteredAndSortedPackages.length)} of{" "}
|
||||
{filteredAndSortedPackages.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Key,
|
||||
LogOut,
|
||||
Mail,
|
||||
MapPin,
|
||||
Monitor,
|
||||
Moon,
|
||||
RefreshCw,
|
||||
Save,
|
||||
@@ -18,7 +22,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
@@ -45,6 +49,18 @@ const Profile = () => {
|
||||
last_name: user?.last_name || "",
|
||||
});
|
||||
|
||||
// Update profileData when user data changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setProfileData({
|
||||
username: user.username || "",
|
||||
email: user.email || "",
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
@@ -141,6 +157,7 @@ const Profile = () => {
|
||||
{ id: "profile", name: "Profile Information", icon: User },
|
||||
{ id: "password", name: "Change Password", icon: Key },
|
||||
{ id: "tfa", name: "Multi-Factor Authentication", icon: Smartphone },
|
||||
{ id: "sessions", name: "Active Sessions", icon: Monitor },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -521,6 +538,9 @@ const Profile = () => {
|
||||
|
||||
{/* Multi-Factor Authentication Tab */}
|
||||
{activeTab === "tfa" && <TfaTab />}
|
||||
|
||||
{/* Sessions Tab */}
|
||||
{activeTab === "sessions" && <SessionsTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1060,4 +1080,256 @@ const TfaTab = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Sessions Tab Component
|
||||
const SessionsTab = () => {
|
||||
const _queryClient = useQueryClient();
|
||||
const [_isLoading, _setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: "", text: "" });
|
||||
|
||||
// Fetch user sessions
|
||||
const {
|
||||
data: sessionsData,
|
||||
isLoading: sessionsLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["user-sessions"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/v1/auth/sessions", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch sessions");
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
// Revoke individual session mutation
|
||||
const revokeSessionMutation = useMutation({
|
||||
mutationFn: async (sessionId) => {
|
||||
const response = await fetch(`/api/v1/auth/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to revoke session");
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMessage({ type: "success", text: "Session revoked successfully" });
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
// Revoke all sessions mutation
|
||||
const revokeAllSessionsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await fetch("/api/v1/auth/sessions", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to revoke sessions");
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "All other sessions revoked successfully",
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const formatRelativeTime = (dateString) => {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diff = now - date;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
|
||||
return "Just now";
|
||||
};
|
||||
|
||||
const handleRevokeSession = (sessionId) => {
|
||||
if (window.confirm("Are you sure you want to revoke this session?")) {
|
||||
revokeSessionMutation.mutate(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = () => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to revoke all other sessions? This will log you out of all other devices.",
|
||||
)
|
||||
) {
|
||||
revokeAllSessionsMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
Active Sessions
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
Manage your active sessions and devices. You can see where you're
|
||||
logged in and revoke access for any device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message.text && (
|
||||
<div
|
||||
className={`rounded-md p-4 ${
|
||||
message.type === "success"
|
||||
? "bg-success-50 border border-success-200 text-success-700"
|
||||
: "bg-danger-50 border border-danger-200 text-danger-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex">
|
||||
{message.type === "success" ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<p className="text-sm">{message.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions List */}
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : sessionsData?.sessions?.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* Revoke All Button */}
|
||||
{sessionsData.sessions.filter((s) => !s.is_current_session).length >
|
||||
0 && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={revokeAllSessionsMutation.isPending}
|
||||
className="inline-flex items-center px-4 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{revokeAllSessionsMutation.isPending
|
||||
? "Revoking..."
|
||||
: "Revoke All Other Sessions"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions */}
|
||||
{sessionsData.sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`border rounded-lg p-4 ${
|
||||
session.is_current_session
|
||||
? "border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
: "border-secondary-200 bg-white dark:border-secondary-700 dark:bg-secondary-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Monitor className="h-5 w-5 text-secondary-500" />
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{session.device_info?.browser} on{" "}
|
||||
{session.device_info?.os}
|
||||
</h4>
|
||||
{session.is_current_session && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
|
||||
Current Session
|
||||
</span>
|
||||
)}
|
||||
{session.tfa_remember_me && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200">
|
||||
Remembered
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{session.device_info?.device} • {session.ip_address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span>
|
||||
{session.location_info?.city},{" "}
|
||||
{session.location_info?.country}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
Last active: {formatRelativeTime(session.last_activity)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Created: {formatDate(session.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Login count: {session.login_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!session.is_current_session && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={revokeSessionMutation.isPending}
|
||||
className="ml-4 inline-flex items-center px-3 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Monitor className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
No active sessions
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
You don't have any active sessions at the moment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
|
||||
699
frontend/src/pages/Queue.jsx
Normal file
699
frontend/src/pages/Queue.jsx
Normal file
@@ -0,0 +1,699 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
Filter,
|
||||
Package,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const Queue = () => {
|
||||
const [activeTab, setActiveTab] = useState("server");
|
||||
const [filterStatus, setFilterStatus] = useState("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Mock data for demonstration
|
||||
const serverQueueData = [
|
||||
{
|
||||
id: 1,
|
||||
type: "Server Update Check",
|
||||
description: "Check for server updates from GitHub",
|
||||
status: "running",
|
||||
priority: "high",
|
||||
createdAt: "2024-01-15 10:30:00",
|
||||
estimatedCompletion: "2024-01-15 10:35:00",
|
||||
progress: 75,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Session Cleanup",
|
||||
description: "Clear expired login sessions",
|
||||
status: "pending",
|
||||
priority: "medium",
|
||||
createdAt: "2024-01-15 10:25:00",
|
||||
estimatedCompletion: "2024-01-15 10:40:00",
|
||||
progress: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "Database Optimization",
|
||||
description: "Optimize database indexes and cleanup old records",
|
||||
status: "completed",
|
||||
priority: "low",
|
||||
createdAt: "2024-01-15 09:00:00",
|
||||
completedAt: "2024-01-15 09:45:00",
|
||||
progress: 100,
|
||||
retryCount: 0,
|
||||
maxRetries: 1,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "Backup Creation",
|
||||
description: "Create system backup",
|
||||
status: "failed",
|
||||
priority: "high",
|
||||
createdAt: "2024-01-15 08:00:00",
|
||||
errorMessage: "Insufficient disk space",
|
||||
progress: 45,
|
||||
retryCount: 2,
|
||||
maxRetries: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const agentQueueData = [
|
||||
{
|
||||
id: 1,
|
||||
hostname: "web-server-01",
|
||||
ip: "192.168.1.100",
|
||||
type: "Agent Update Collection",
|
||||
description: "Agent v1.2.7 → v1.2.8",
|
||||
status: "pending",
|
||||
priority: "medium",
|
||||
lastCommunication: "2024-01-15 10:00:00",
|
||||
nextExpectedCommunication: "2024-01-15 11:00:00",
|
||||
currentVersion: "1.2.7",
|
||||
targetVersion: "1.2.8",
|
||||
retryCount: 0,
|
||||
maxRetries: 5,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
hostname: "db-server-02",
|
||||
ip: "192.168.1.101",
|
||||
type: "Data Collection",
|
||||
description: "Collect package and system information",
|
||||
status: "running",
|
||||
priority: "high",
|
||||
lastCommunication: "2024-01-15 10:15:00",
|
||||
nextExpectedCommunication: "2024-01-15 11:15:00",
|
||||
currentVersion: "1.2.8",
|
||||
targetVersion: "1.2.8",
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
hostname: "app-server-03",
|
||||
ip: "192.168.1.102",
|
||||
type: "Agent Update Collection",
|
||||
description: "Agent v1.2.6 → v1.2.8",
|
||||
status: "completed",
|
||||
priority: "low",
|
||||
lastCommunication: "2024-01-15 09:30:00",
|
||||
completedAt: "2024-01-15 09:45:00",
|
||||
currentVersion: "1.2.8",
|
||||
targetVersion: "1.2.8",
|
||||
retryCount: 0,
|
||||
maxRetries: 5,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
hostname: "test-server-04",
|
||||
ip: "192.168.1.103",
|
||||
type: "Data Collection",
|
||||
description: "Collect package and system information",
|
||||
status: "failed",
|
||||
priority: "medium",
|
||||
lastCommunication: "2024-01-15 08:00:00",
|
||||
errorMessage: "Connection timeout",
|
||||
retryCount: 3,
|
||||
maxRetries: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const patchQueueData = [
|
||||
{
|
||||
id: 1,
|
||||
hostname: "web-server-01",
|
||||
ip: "192.168.1.100",
|
||||
packages: ["nginx", "openssl", "curl"],
|
||||
type: "Security Updates",
|
||||
description: "Apply critical security patches",
|
||||
status: "pending",
|
||||
priority: "high",
|
||||
scheduledFor: "2024-01-15 19:00:00",
|
||||
lastCommunication: "2024-01-15 18:00:00",
|
||||
nextExpectedCommunication: "2024-01-15 19:00:00",
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
hostname: "db-server-02",
|
||||
ip: "192.168.1.101",
|
||||
packages: ["postgresql", "python3"],
|
||||
type: "Feature Updates",
|
||||
description: "Update database and Python packages",
|
||||
status: "running",
|
||||
priority: "medium",
|
||||
scheduledFor: "2024-01-15 20:00:00",
|
||||
lastCommunication: "2024-01-15 19:15:00",
|
||||
nextExpectedCommunication: "2024-01-15 20:15:00",
|
||||
retryCount: 0,
|
||||
maxRetries: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
hostname: "app-server-03",
|
||||
ip: "192.168.1.102",
|
||||
packages: ["nodejs", "npm"],
|
||||
type: "Maintenance Updates",
|
||||
description: "Update Node.js and npm packages",
|
||||
status: "completed",
|
||||
priority: "low",
|
||||
scheduledFor: "2024-01-15 18:30:00",
|
||||
completedAt: "2024-01-15 18:45:00",
|
||||
retryCount: 0,
|
||||
maxRetries: 2,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
hostname: "test-server-04",
|
||||
ip: "192.168.1.103",
|
||||
packages: ["docker", "docker-compose"],
|
||||
type: "Security Updates",
|
||||
description: "Update Docker components",
|
||||
status: "failed",
|
||||
priority: "high",
|
||||
scheduledFor: "2024-01-15 17:00:00",
|
||||
errorMessage: "Package conflicts detected",
|
||||
retryCount: 2,
|
||||
maxRetries: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <RefreshCw className="h-4 w-4 text-blue-500 animate-spin" />;
|
||||
case "completed":
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case "pending":
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
case "paused":
|
||||
return <Pause className="h-4 w-4 text-gray-500" />;
|
||||
default:
|
||||
return <AlertCircle className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "completed":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "failed":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "pending":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "paused":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority) => {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "medium":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "low":
|
||||
return "bg-green-100 text-green-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = (data) => {
|
||||
let filtered = data;
|
||||
|
||||
if (filterStatus !== "all") {
|
||||
filtered = filtered.filter((item) => item.status === filterStatus);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(
|
||||
(item) =>
|
||||
item.hostname?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.type?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "server",
|
||||
name: "Server Queue",
|
||||
icon: Server,
|
||||
data: serverQueueData,
|
||||
count: serverQueueData.length,
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
name: "Agent Queue",
|
||||
icon: Download,
|
||||
data: agentQueueData,
|
||||
count: agentQueueData.length,
|
||||
},
|
||||
{
|
||||
id: "patch",
|
||||
name: "Patch Management",
|
||||
icon: Package,
|
||||
data: patchQueueData,
|
||||
count: patchQueueData.length,
|
||||
},
|
||||
];
|
||||
|
||||
const renderServerQueueItem = (item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{item.type}
|
||||
</h3>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(item.status)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getPriorityColor(item.priority)}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{item.status === "running" && (
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{item.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="font-medium">Created:</span> {item.createdAt}
|
||||
</div>
|
||||
{item.status === "running" && (
|
||||
<div>
|
||||
<span className="font-medium">ETA:</span>{" "}
|
||||
{item.estimatedCompletion}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "completed" && (
|
||||
<div>
|
||||
<span className="font-medium">Completed:</span>{" "}
|
||||
{item.completedAt}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "failed" && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">Error:</span> {item.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.retryCount > 0 && (
|
||||
<div className="mt-2 text-xs text-orange-600">
|
||||
Retries: {item.retryCount}/{item.maxRetries}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-4">
|
||||
{item.status === "running" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{item.status === "paused" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{item.status === "failed" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAgentQueueItem = (item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{item.hostname}
|
||||
</h3>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(item.status)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getPriorityColor(item.priority)}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{item.type}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-3">{item.description}</p>
|
||||
|
||||
{item.type === "Agent Update Collection" && (
|
||||
<div className="mb-3 p-2 bg-gray-50 dark:bg-gray-700 rounded">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">Version:</span>{" "}
|
||||
{item.currentVersion} → {item.targetVersion}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="font-medium">IP:</span> {item.ip}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Last Comm:</span>{" "}
|
||||
{item.lastCommunication}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Next Expected:</span>{" "}
|
||||
{item.nextExpectedCommunication}
|
||||
</div>
|
||||
{item.status === "completed" && (
|
||||
<div>
|
||||
<span className="font-medium">Completed:</span>{" "}
|
||||
{item.completedAt}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "failed" && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">Error:</span> {item.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.retryCount > 0 && (
|
||||
<div className="mt-2 text-xs text-orange-600">
|
||||
Retries: {item.retryCount}/{item.maxRetries}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-4">
|
||||
{item.status === "failed" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPatchQueueItem = (item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{item.hostname}
|
||||
</h3>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(item.status)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getPriorityColor(item.priority)}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{item.type}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-3">{item.description}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span className="font-medium">Packages:</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.packages.map((pkg) => (
|
||||
<span
|
||||
key={pkg}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded"
|
||||
>
|
||||
{pkg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="font-medium">IP:</span> {item.ip}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Scheduled:</span>{" "}
|
||||
{item.scheduledFor}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Last Comm:</span>{" "}
|
||||
{item.lastCommunication}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Next Expected:</span>{" "}
|
||||
{item.nextExpectedCommunication}
|
||||
</div>
|
||||
{item.status === "completed" && (
|
||||
<div>
|
||||
<span className="font-medium">Completed:</span>{" "}
|
||||
{item.completedAt}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "failed" && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">Error:</span> {item.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.retryCount > 0 && (
|
||||
<div className="mt-2 text-xs text-orange-600">
|
||||
Retries: {item.retryCount}/{item.maxRetries}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-4">
|
||||
{item.status === "failed" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const currentTab = tabs.find((tab) => tab.id === activeTab);
|
||||
const filteredItems = filteredData(currentTab?.data || []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Queue Management
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Monitor and manage server operations, agent communications, and
|
||||
patch deployments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
activeTab === tab.id
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.name}
|
||||
<span className="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-0.5 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search queues..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="paused">Paused</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
More Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue Items */}
|
||||
<div className="space-y-4">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
No queue items found
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{searchQuery
|
||||
? "Try adjusting your search criteria"
|
||||
: "No items match the current filters"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => {
|
||||
switch (activeTab) {
|
||||
case "server":
|
||||
return renderServerQueueItem(item);
|
||||
case "agent":
|
||||
return renderAgentQueueItem(item);
|
||||
case "patch":
|
||||
return renderPatchQueueItem(item);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Queue;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Check,
|
||||
Columns,
|
||||
Database,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
@@ -15,21 +14,34 @@ import {
|
||||
Server,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Unlock,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { repositoryAPI } from "../utils/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { dashboardAPI, repositoryAPI } from "../utils/api";
|
||||
|
||||
const Repositories = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterType, setFilterType] = useState("all"); // all, secure, insecure
|
||||
const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
|
||||
const [hostFilter, setHostFilter] = useState("");
|
||||
const [sortField, setSortField] = useState("name");
|
||||
const [sortDirection, setSortDirection] = useState("asc");
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
||||
const [deleteModalData, setDeleteModalData] = useState(null);
|
||||
|
||||
// Handle host filter from URL parameter
|
||||
useEffect(() => {
|
||||
const hostParam = searchParams.get("host");
|
||||
if (hostParam) {
|
||||
setHostFilter(hostParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Column configuration
|
||||
const [columnConfig, setColumnConfig] = useState(() => {
|
||||
@@ -80,6 +92,26 @@ const Repositories = () => {
|
||||
queryFn: () => repositoryAPI.getStats().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Fetch host information when filtering by host
|
||||
const { data: hosts } = useQuery({
|
||||
queryKey: ["hosts"],
|
||||
queryFn: () => dashboardAPI.getHosts().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!hostFilter,
|
||||
});
|
||||
|
||||
// Get the filtered host information
|
||||
const filteredHost = hosts?.find((host) => host.id === hostFilter);
|
||||
|
||||
// Delete repository mutation
|
||||
const deleteRepositoryMutation = useMutation({
|
||||
mutationFn: (repositoryId) => repositoryAPI.delete(repositoryId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["repositories"]);
|
||||
queryClient.invalidateQueries(["repository-stats"]);
|
||||
},
|
||||
});
|
||||
|
||||
// Get visible columns in order
|
||||
const visibleColumns = columnConfig
|
||||
.filter((col) => col.visible)
|
||||
@@ -138,6 +170,32 @@ const Repositories = () => {
|
||||
updateColumnConfig(defaultConfig);
|
||||
};
|
||||
|
||||
const handleDeleteRepository = (repo, e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setDeleteModalData({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
hostCount: repo.hostCount || 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowClick = (repo) => {
|
||||
navigate(`/repositories/${repo.id}`);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteModalData) {
|
||||
deleteRepositoryMutation.mutate(deleteModalData.id);
|
||||
setDeleteModalData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
setDeleteModalData(null);
|
||||
};
|
||||
|
||||
// Filter and sort repositories
|
||||
const filteredAndSortedRepositories = useMemo(() => {
|
||||
if (!repositories) return [];
|
||||
@@ -165,7 +223,11 @@ const Repositories = () => {
|
||||
(filterStatus === "active" && repo.is_active === true) ||
|
||||
(filterStatus === "inactive" && repo.is_active === false);
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
// Filter by host if hostFilter is set
|
||||
const matchesHost =
|
||||
!hostFilter || repo.hosts?.some((host) => host.id === hostFilter);
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus && matchesHost;
|
||||
});
|
||||
|
||||
// Sort repositories
|
||||
@@ -200,6 +262,7 @@ const Repositories = () => {
|
||||
filterStatus,
|
||||
sortField,
|
||||
sortDirection,
|
||||
hostFilter,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -225,6 +288,56 @@ const Repositories = () => {
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteModalData && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Repository
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Are you sure you want to delete{" "}
|
||||
<strong>"{deleteModalData.name}"</strong>?
|
||||
</p>
|
||||
{deleteModalData.hostCount > 0 && (
|
||||
<p className="text-amber-600 dark:text-amber-400 text-sm">
|
||||
⚠️ This repository is currently assigned to{" "}
|
||||
{deleteModalData.hostCount} host
|
||||
{deleteModalData.hostCount !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-2">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelDelete}
|
||||
className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
{deleteRepositoryMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete Repository"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -334,6 +447,31 @@ const Repositories = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host Filter Indicator */}
|
||||
{hostFilter && filteredHost && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-primary-50 dark:bg-primary-900 border border-primary-200 dark:border-primary-700 rounded-md">
|
||||
<Server className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span className="text-sm text-primary-700 dark:text-primary-300">
|
||||
Filtered by: {filteredHost.friendly_name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHostFilter("");
|
||||
// Update URL to remove host parameter
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.delete("host");
|
||||
navigate(`/repositories?${newSearchParams.toString()}`, {
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
className="text-primary-500 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-200"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Filter */}
|
||||
<div className="sm:w-48">
|
||||
<select
|
||||
@@ -415,7 +553,8 @@ const Repositories = () => {
|
||||
{filteredAndSortedRepositories.map((repo) => (
|
||||
<tr
|
||||
key={repo.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors cursor-pointer"
|
||||
onClick={() => handleRowClick(repo)}
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<td
|
||||
@@ -513,19 +652,23 @@ const Repositories = () => {
|
||||
case "hostCount":
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{repo.host_count}</span>
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{repo.hostCount}</span>
|
||||
</div>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<Link
|
||||
to={`/repositories/${repo.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
|
||||
>
|
||||
View
|
||||
<Eye className="h-3 w-3" />
|
||||
</Link>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDeleteRepository(repo, e)}
|
||||
className="text-orange-600 hover:text-red-900 dark:text-orange-600 dark:hover:text-red-400 flex items-center gap-1"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
title="Delete repository"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -6,17 +6,18 @@ import {
|
||||
Database,
|
||||
Globe,
|
||||
Lock,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
Unlock,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { repositoryAPI } from "../utils/api";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { formatRelativeTime, repositoryAPI } from "../utils/api";
|
||||
|
||||
const RepositoryDetail = () => {
|
||||
const isActiveId = useId();
|
||||
@@ -24,9 +25,14 @@ const RepositoryDetail = () => {
|
||||
const priorityId = useId();
|
||||
const descriptionId = useId();
|
||||
const { repositoryId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [formData, setFormData] = useState({});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// Fetch repository details
|
||||
const {
|
||||
@@ -39,6 +45,49 @@ const RepositoryDetail = () => {
|
||||
enabled: !!repositoryId,
|
||||
});
|
||||
|
||||
const hosts = repository?.host_repositories || [];
|
||||
|
||||
// Filter and paginate hosts
|
||||
const filteredAndPaginatedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = hosts.filter(
|
||||
(hostRepo) =>
|
||||
hostRepo.hosts.friendly_name
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
hostRepo.hosts.hostname
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
hostRepo.hosts.ip?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return filtered.slice(startIndex, endIndex);
|
||||
}, [hosts, searchTerm, currentPage, pageSize]);
|
||||
|
||||
const totalPages = Math.ceil(
|
||||
(searchTerm
|
||||
? hosts.filter(
|
||||
(hostRepo) =>
|
||||
hostRepo.hosts.friendly_name
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
hostRepo.hosts.hostname
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
hostRepo.hosts.ip?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
).length
|
||||
: hosts.length) / pageSize,
|
||||
);
|
||||
|
||||
const handleHostClick = (hostId) => {
|
||||
navigate(`/hosts/${hostId}`);
|
||||
};
|
||||
|
||||
// Update repository mutation
|
||||
const updateRepositoryMutation = useMutation({
|
||||
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
|
||||
@@ -49,6 +98,15 @@ const RepositoryDetail = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Delete repository mutation
|
||||
const deleteRepositoryMutation = useMutation({
|
||||
mutationFn: () => repositoryAPI.delete(repositoryId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["repositories"]);
|
||||
navigate("/repositories");
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
setFormData({
|
||||
name: repository.name,
|
||||
@@ -68,6 +126,19 @@ const RepositoryDetail = () => {
|
||||
setFormData({});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
deleteRepositoryMutation.mutate();
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -127,6 +198,56 @@ const RepositoryDetail = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<AlertTriangle className="h-6 w-6 text-red-500 mr-3" />
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Repository
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Are you sure you want to delete{" "}
|
||||
<strong>"{repository?.name}"</strong>?
|
||||
</p>
|
||||
{repository?.host_repositories?.length > 0 && (
|
||||
<p className="text-amber-600 dark:text-amber-400 text-sm">
|
||||
⚠️ This repository is currently assigned to{" "}
|
||||
{repository.host_repositories.length} host
|
||||
{repository.host_repositories.length !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-red-600 dark:text-red-400 text-sm mt-2">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelDelete}
|
||||
className="px-4 py-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200 transition-colors"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
{deleteRepositoryMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete Repository"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -157,9 +278,6 @@ const RepositoryDetail = () => {
|
||||
{repository.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
|
||||
Repository configuration and host assignments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -185,15 +303,30 @@ const RepositoryDetail = () => {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={handleEdit} className="btn-primary">
|
||||
Edit Repository
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="btn-outline border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:border-red-700 flex items-center gap-2"
|
||||
disabled={deleteRepositoryMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleteRepositoryMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="btn-primary"
|
||||
>
|
||||
Edit Repository
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository Information */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Repository Information
|
||||
@@ -369,80 +502,159 @@ const RepositoryDetail = () => {
|
||||
</div>
|
||||
|
||||
{/* Hosts Using This Repository */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Hosts Using This Repository (
|
||||
{repository.host_repositories?.length || 0})
|
||||
</h2>
|
||||
</div>
|
||||
{!repository.host_repositories ||
|
||||
repository.host_repositories.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Server className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
|
||||
No hosts using this repository
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
This repository hasn't been reported by any hosts yet.
|
||||
</p>
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Hosts Using This Repository ({hosts.length})
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{repository.host_repositories.map((hostRepo) => (
|
||||
<div
|
||||
key={hostRepo.id}
|
||||
className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
hostRepo.hosts.status === "active"
|
||||
? "bg-green-500"
|
||||
: hostRepo.hosts.status === "pending"
|
||||
? "bg-yellow-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<Link
|
||||
to={`/hosts/${hostRepo.hosts.id}`}
|
||||
className="text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
{hostRepo.hosts.friendly_name}
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
<span>IP: {hostRepo.hosts.ip}</span>
|
||||
<span>
|
||||
OS: {hostRepo.hosts.os_type}{" "}
|
||||
{hostRepo.hosts.os_version}
|
||||
</span>
|
||||
<span>
|
||||
Last Update:{" "}
|
||||
{new Date(
|
||||
hostRepo.hosts.last_update,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search hosts..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{filteredAndPaginatedHosts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
{searchTerm
|
||||
? "No hosts match your search"
|
||||
: "This repository hasn't been reported by any hosts yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Host
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Operating System
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Last Checked
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Last Update
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndPaginatedHosts.map((hostRepo) => (
|
||||
<tr
|
||||
key={hostRepo.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 cursor-pointer transition-colors"
|
||||
onClick={() => handleHostClick(hostRepo.hosts.id)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full mr-3 ${
|
||||
hostRepo.hosts.status === "active"
|
||||
? "bg-success-500"
|
||||
: hostRepo.hosts.status === "pending"
|
||||
? "bg-warning-500"
|
||||
: "bg-danger-500"
|
||||
}`}
|
||||
/>
|
||||
<Server className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{hostRepo.hosts.friendly_name ||
|
||||
hostRepo.hosts.hostname}
|
||||
</div>
|
||||
{hostRepo.hosts.friendly_name &&
|
||||
hostRepo.hosts.hostname && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{hostRepo.hosts.hostname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{hostRepo.hosts.os_type} {hostRepo.hosts.os_version}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{hostRepo.last_checked
|
||||
? formatRelativeTime(hostRepo.last_checked)
|
||||
: "Never"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{hostRepo.hosts.last_update
|
||||
? formatRelativeTime(hostRepo.hosts.last_update)
|
||||
: "Never"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-3 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-600 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Rows per page:
|
||||
</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-sm border border-secondary-300 dark:border-secondary-600 rounded px-2 py-1 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Last Checked
|
||||
</div>
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{new Date(hostRepo.last_checked).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Clock,
|
||||
Code,
|
||||
Download,
|
||||
Image,
|
||||
Plus,
|
||||
Save,
|
||||
Server,
|
||||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -80,6 +82,15 @@ const Settings = () => {
|
||||
});
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
|
||||
// Logo management state
|
||||
const [logoUploadState, setLogoUploadState] = useState({
|
||||
dark: { uploading: false, error: null },
|
||||
light: { uploading: false, error: null },
|
||||
favicon: { uploading: false, error: null },
|
||||
});
|
||||
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
||||
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
||||
|
||||
// Version checking state
|
||||
const [versionInfo, setVersionInfo] = useState({
|
||||
currentVersion: null, // Will be loaded from API
|
||||
@@ -192,6 +203,37 @@ const Settings = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Logo upload mutation
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: ({ logoType, fileContent, fileName }) =>
|
||||
fetch("/api/v1/settings/logos/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
body: JSON.stringify({ logoType, fileContent, fileName }),
|
||||
}).then((res) => res.json()),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries(["settings"]);
|
||||
setLogoUploadState((prev) => ({
|
||||
...prev,
|
||||
[variables.logoType]: { uploading: false, error: null },
|
||||
}));
|
||||
setShowLogoUploadModal(false);
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
console.error("Upload logo error:", error);
|
||||
setLogoUploadState((prev) => ({
|
||||
...prev,
|
||||
[variables.logoType]: {
|
||||
uploading: false,
|
||||
error: error.message || "Failed to upload logo",
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Load current version on component mount
|
||||
useEffect(() => {
|
||||
const loadCurrentVersion = async () => {
|
||||
@@ -556,6 +598,181 @@ const Settings = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Logo Management Section */}
|
||||
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center mb-4">
|
||||
<Image className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Logo & Branding
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-4">
|
||||
Customize your PatchMon installation with custom logos and
|
||||
favicon.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Dark Logo */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
|
||||
Dark Logo
|
||||
</h4>
|
||||
{settings?.logo_dark && (
|
||||
<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
|
||||
<img
|
||||
src={settings.logo_dark}
|
||||
alt="Dark Logo"
|
||||
className="max-h-12 max-w-full object-contain"
|
||||
onError={(e) => {
|
||||
e.target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
|
||||
{settings?.logo_dark
|
||||
? settings.logo_dark.split("/").pop()
|
||||
: "Default"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedLogoType("dark");
|
||||
setShowLogoUploadModal(true);
|
||||
}}
|
||||
disabled={logoUploadState.dark.uploading}
|
||||
className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
|
||||
>
|
||||
{logoUploadState.dark.uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-3 w-3" />
|
||||
Upload
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{logoUploadState.dark.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
{logoUploadState.dark.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Light Logo */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
|
||||
Light Logo
|
||||
</h4>
|
||||
{settings?.logo_light && (
|
||||
<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
|
||||
<img
|
||||
src={settings.logo_light}
|
||||
alt="Light Logo"
|
||||
className="max-h-12 max-w-full object-contain"
|
||||
onError={(e) => {
|
||||
e.target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
|
||||
{settings?.logo_light
|
||||
? settings.logo_light.split("/").pop()
|
||||
: "Default"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedLogoType("light");
|
||||
setShowLogoUploadModal(true);
|
||||
}}
|
||||
disabled={logoUploadState.light.uploading}
|
||||
className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
|
||||
>
|
||||
{logoUploadState.light.uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-3 w-3" />
|
||||
Upload
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{logoUploadState.light.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
{logoUploadState.light.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favicon */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
|
||||
Favicon
|
||||
</h4>
|
||||
{settings?.favicon && (
|
||||
<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
|
||||
<img
|
||||
src={settings.favicon}
|
||||
alt="Favicon"
|
||||
className="h-8 w-8 object-contain"
|
||||
onError={(e) => {
|
||||
e.target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
|
||||
{settings?.favicon
|
||||
? settings.favicon.split("/").pop()
|
||||
: "Default"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedLogoType("favicon");
|
||||
setShowLogoUploadModal(true);
|
||||
}}
|
||||
disabled={logoUploadState.favicon.uploading}
|
||||
className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
|
||||
>
|
||||
{logoUploadState.favicon.uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-3 w-3" />
|
||||
Upload
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{logoUploadState.favicon.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
{logoUploadState.favicon.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>Supported formats:</strong> PNG, JPG, SVG.{" "}
|
||||
<strong>Max size:</strong> 5MB.
|
||||
<strong> Recommended sizes:</strong> 200x60px for logos,
|
||||
32x32px for favicon.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Interval */}
|
||||
<div>
|
||||
<label
|
||||
@@ -1319,6 +1536,18 @@ const Settings = () => {
|
||||
error={uploadAgentMutation.error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Logo Upload Modal */}
|
||||
{showLogoUploadModal && (
|
||||
<LogoUploadModal
|
||||
isOpen={showLogoUploadModal}
|
||||
onClose={() => setShowLogoUploadModal(false)}
|
||||
onSubmit={uploadLogoMutation.mutate}
|
||||
isLoading={uploadLogoMutation.isPending}
|
||||
error={uploadLogoMutation.error}
|
||||
logoType={selectedLogoType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1467,4 +1696,181 @@ const AgentUploadModal = ({ isOpen, onClose, onSubmit, isLoading, error }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Logo Upload Modal Component
|
||||
const LogoUploadModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
error,
|
||||
logoType,
|
||||
}) => {
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/svg+xml",
|
||||
];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
setUploadError("Please select a PNG, JPG, or SVG file");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB limit)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("File size must be less than 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
setUploadError("");
|
||||
|
||||
// Create preview URL
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
setUploadError("");
|
||||
|
||||
if (!selectedFile) {
|
||||
setUploadError("Please select a file");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert file to base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const base64 = event.target.result;
|
||||
onSubmit({
|
||||
logoType,
|
||||
fileContent: base64,
|
||||
fileName: selectedFile.name,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(selectedFile);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl(null);
|
||||
setUploadError("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Upload{" "}
|
||||
{logoType === "favicon"
|
||||
? "Favicon"
|
||||
: `${logoType.charAt(0).toUpperCase() + logoType.slice(1)} Logo`}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Select File
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
|
||||
onChange={handleFileSelect}
|
||||
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Supported formats: PNG, JPG, SVG. Max size: 5MB.
|
||||
{logoType === "favicon"
|
||||
? " Recommended: 32x32px SVG."
|
||||
: " Recommended: 200x60px."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{previewUrl && (
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Preview
|
||||
</div>
|
||||
<div className="flex items-center justify-center p-4 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-600">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className={`object-contain ${
|
||||
logoType === "favicon" ? "h-8 w-8" : "max-h-16 max-w-full"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(uploadError || error) && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
{uploadError ||
|
||||
error?.response?.data?.error ||
|
||||
error?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
|
||||
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p className="font-medium">Important:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1">
|
||||
<li>This will replace the current {logoType} logo</li>
|
||||
<li>A backup will be created automatically</li>
|
||||
<li>The change will be applied immediately</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button type="button" onClick={handleClose} className="btn-outline">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !selectedFile}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isLoading ? "Uploading..." : "Upload Logo"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
||||
@@ -1,47 +1,751 @@
|
||||
import { Plug } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Plus,
|
||||
Server,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import SettingsLayout from "../../components/SettingsLayout";
|
||||
import api from "../../utils/api";
|
||||
|
||||
const Integrations = () => {
|
||||
const [activeTab, setActiveTab] = useState("proxmox");
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [host_groups, setHostGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [show_create_modal, setShowCreateModal] = useState(false);
|
||||
const [new_token, setNewToken] = useState(null);
|
||||
const [show_secret, setShowSecret] = useState(false);
|
||||
const [server_url, setServerUrl] = useState("");
|
||||
const [force_proxmox_install, setForceProxmoxInstall] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [form_data, setFormData] = useState({
|
||||
token_name: "",
|
||||
max_hosts_per_day: 100,
|
||||
default_host_group_id: "",
|
||||
allowed_ip_ranges: "",
|
||||
expires_at: "",
|
||||
});
|
||||
|
||||
const [copy_success, setCopySuccess] = useState({});
|
||||
|
||||
// Helper function to build Proxmox enrollment URL with optional force flag
|
||||
const getProxmoxUrl = () => {
|
||||
const baseUrl = `${server_url}/api/v1/auto-enrollment/proxmox-lxc?token_key=${new_token.token_key}&token_secret=${new_token.token_secret}`;
|
||||
return force_proxmox_install ? `${baseUrl}&force=true` : baseUrl;
|
||||
};
|
||||
|
||||
const handleTabChange = (tabName) => {
|
||||
setActiveTab(tabName);
|
||||
};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount
|
||||
useEffect(() => {
|
||||
load_tokens();
|
||||
load_host_groups();
|
||||
load_server_url();
|
||||
}, []);
|
||||
|
||||
const load_tokens = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get("/auto-enrollment/tokens");
|
||||
setTokens(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tokens:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const load_host_groups = async () => {
|
||||
try {
|
||||
const response = await api.get("/host-groups");
|
||||
setHostGroups(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load host groups:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const load_server_url = async () => {
|
||||
try {
|
||||
const response = await api.get("/settings");
|
||||
setServerUrl(response.data.server_url || window.location.origin);
|
||||
} catch (error) {
|
||||
console.error("Failed to load server URL:", error);
|
||||
setServerUrl(window.location.origin);
|
||||
}
|
||||
};
|
||||
|
||||
const create_token = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const data = {
|
||||
token_name: form_data.token_name,
|
||||
max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10),
|
||||
allowed_ip_ranges: form_data.allowed_ip_ranges
|
||||
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
|
||||
: [],
|
||||
metadata: {
|
||||
integration_type: "proxmox-lxc",
|
||||
},
|
||||
};
|
||||
|
||||
// Only add optional fields if they have values
|
||||
if (form_data.default_host_group_id) {
|
||||
data.default_host_group_id = form_data.default_host_group_id;
|
||||
}
|
||||
if (form_data.expires_at) {
|
||||
data.expires_at = form_data.expires_at;
|
||||
}
|
||||
|
||||
const response = await api.post("/auto-enrollment/tokens", data);
|
||||
setNewToken(response.data.token);
|
||||
setShowCreateModal(false);
|
||||
load_tokens();
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
token_name: "",
|
||||
max_hosts_per_day: 100,
|
||||
default_host_group_id: "",
|
||||
allowed_ip_ranges: "",
|
||||
expires_at: "",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create token:", error);
|
||||
const error_message = error.response?.data?.errors
|
||||
? error.response.data.errors.map((e) => e.msg).join(", ")
|
||||
: error.response?.data?.error || "Failed to create token";
|
||||
alert(error_message);
|
||||
}
|
||||
};
|
||||
|
||||
const delete_token = async (id, name) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete the token "${name}"? This action cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/auto-enrollment/tokens/${id}`);
|
||||
load_tokens();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete token:", error);
|
||||
alert(error.response?.data?.error || "Failed to delete token");
|
||||
}
|
||||
};
|
||||
|
||||
const toggle_token_active = async (id, current_status) => {
|
||||
try {
|
||||
await api.patch(`/auto-enrollment/tokens/${id}`, {
|
||||
is_active: !current_status,
|
||||
});
|
||||
load_tokens();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle token:", error);
|
||||
alert(error.response?.data?.error || "Failed to toggle token");
|
||||
}
|
||||
};
|
||||
|
||||
const copy_to_clipboard = (text, key) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopySuccess({ ...copy_success, [key]: true });
|
||||
setTimeout(() => {
|
||||
setCopySuccess({ ...copy_success, [key]: false });
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const format_date = (date_string) => {
|
||||
if (!date_string) return "Never";
|
||||
return new Date(date_string).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Integrations
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Connect PatchMon to third-party services
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Integrations
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Manage auto-enrollment tokens for Proxmox and other integrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Coming Soon Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-secondary-100 dark:bg-secondary-700 rounded-lg flex items-center justify-center">
|
||||
<Plug className="h-6 w-6 text-secondary-700 dark:text-secondary-200" />
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg overflow-hidden">
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600 flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("proxmox")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === "proxmox"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
}`}
|
||||
>
|
||||
Proxmox LXC
|
||||
</button>
|
||||
{/* Future tabs can be added here */}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{/* Proxmox Tab */}
|
||||
{activeTab === "proxmox" && (
|
||||
<div className="space-y-6">
|
||||
{/* Header with New Token Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
|
||||
<Server className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Proxmox LXC Auto-Enrollment
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Automatically discover and enroll LXC containers from
|
||||
Proxmox hosts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Token List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-secondary-600 dark:text-secondary-400">
|
||||
<p>No auto-enrollment tokens created yet.</p>
|
||||
<p className="text-sm mt-2">
|
||||
Create a token to enable automatic host enrollment from
|
||||
Proxmox.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tokens.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 hover:border-primary-300 dark:hover:border-primary-700 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-medium text-secondary-900 dark:text-white">
|
||||
{token.token_name}
|
||||
</h4>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Proxmox LXC
|
||||
</span>
|
||||
{token.is_active ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-1 rounded">
|
||||
{token.token_key}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
token.token_key,
|
||||
`key-${token.id}`,
|
||||
)
|
||||
}
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
||||
>
|
||||
{copy_success[`key-${token.id}`] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Usage: {token.hosts_created_today}/
|
||||
{token.max_hosts_per_day} hosts today
|
||||
</p>
|
||||
{token.host_groups && (
|
||||
<p>
|
||||
Default Group:{" "}
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${token.host_groups.color}20`,
|
||||
color: token.host_groups.color,
|
||||
}}
|
||||
>
|
||||
{token.host_groups.name}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{token.allowed_ip_ranges?.length > 0 && (
|
||||
<p>
|
||||
Allowed IPs:{" "}
|
||||
{token.allowed_ip_ranges.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<p>Created: {format_date(token.created_at)}</p>
|
||||
{token.last_used_at && (
|
||||
<p>
|
||||
Last Used: {format_date(token.last_used_at)}
|
||||
</p>
|
||||
)}
|
||||
{token.expires_at && (
|
||||
<p>
|
||||
Expires: {format_date(token.expires_at)}
|
||||
{new Date(token.expires_at) < new Date() && (
|
||||
<span className="ml-2 text-red-600 dark:text-red-400">
|
||||
(Expired)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggle_token_active(token.id, token.is_active)
|
||||
}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
token.is_active
|
||||
? "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-300"
|
||||
: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
{token.is_active ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
delete_token(token.id, token.token_name)
|
||||
}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-3">
|
||||
How to Use Auto-Enrollment
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300">
|
||||
<li>
|
||||
Create a new auto-enrollment token using the button above
|
||||
</li>
|
||||
<li>
|
||||
Copy the one-line installation command shown in the
|
||||
success dialog
|
||||
</li>
|
||||
<li>SSH into your Proxmox host as root</li>
|
||||
<li>
|
||||
Paste and run the command - it will automatically discover
|
||||
and enroll all running LXC containers
|
||||
</li>
|
||||
<li>View enrolled containers in the Hosts page</li>
|
||||
</ol>
|
||||
<div className="mt-4 p-3 bg-primary-100 dark:bg-primary-900/40 rounded border border-primary-200 dark:border-primary-700">
|
||||
<p className="text-xs text-primary-800 dark:text-primary-300">
|
||||
<strong>💡 Tip:</strong> You can run the same command
|
||||
multiple times safely - already enrolled containers will
|
||||
be automatically skipped.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Integrations Coming Soon
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
We are building integrations for Slack, Discord, email, and
|
||||
webhooks to streamline alerts and workflows.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
||||
In Development
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Token Modal */}
|
||||
{show_create_modal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
Create Auto-Enrollment Token
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={create_token} className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Token Name *
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form_data.token_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...form_data, token_name: e.target.value })
|
||||
}
|
||||
placeholder="e.g., Proxmox Production"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Max Hosts Per Day
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={form_data.max_hosts_per_day}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...form_data,
|
||||
max_hosts_per_day: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Maximum number of hosts that can be enrolled per day using
|
||||
this token
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Default Host Group (Optional)
|
||||
</span>
|
||||
<select
|
||||
value={form_data.default_host_group_id}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...form_data,
|
||||
default_host_group_id: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value="">No default group</option>
|
||||
{host_groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Auto-enrolled hosts will be assigned to this group
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Allowed IP Addresses (Optional)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form_data.allowed_ip_ranges}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...form_data,
|
||||
allowed_ip_ranges: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., 192.168.1.100, 10.0.0.50"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Comma-separated list of IP addresses allowed to use this
|
||||
token
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Expiration Date (Optional)
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form_data.expires_at}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...form_data, expires_at: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 btn-primary py-2 px-4 rounded-md"
|
||||
>
|
||||
Create Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Token Display Modal */}
|
||||
{new_token && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
Token Created Successfully
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Save these credentials now - the secret will not be shown
|
||||
again!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Important:</strong> Store the token secret securely.
|
||||
You will not be able to view it again after closing this
|
||||
dialog.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Token Name
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={new_token.token_name}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Token Key
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={new_token.token_key}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(new_token.token_key, "new-key")
|
||||
}
|
||||
className="btn-primary flex items-center gap-1 px-3 py-2"
|
||||
>
|
||||
{copy_success["new-key"] ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Token Secret
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type={show_secret ? "text" : "password"}
|
||||
value={new_token.token_secret}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret(!show_secret)}
|
||||
className="p-2 text-secondary-600 hover:text-secondary-800 dark:text-secondary-400 dark:hover:text-secondary-200"
|
||||
>
|
||||
{show_secret ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(new_token.token_secret, "new-secret")
|
||||
}
|
||||
className="btn-primary flex items-center gap-1 px-3 py-2"
|
||||
>
|
||||
{copy_success["new-secret"] ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
One-Line Installation Command
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
Run this command on your Proxmox host to download and
|
||||
execute the enrollment script:
|
||||
</p>
|
||||
|
||||
{/* Force Install Toggle */}
|
||||
<div className="mb-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={force_proxmox_install}
|
||||
onChange={(e) =>
|
||||
setForceProxmoxInstall(e.target.checked)
|
||||
}
|
||||
className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400 dark:bg-secondary-700"
|
||||
/>
|
||||
<span className="text-secondary-800 dark:text-secondary-200">
|
||||
Force install (bypass broken packages in containers)
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Enable this if your LXC containers have broken packages
|
||||
(CloudPanel, WHM, etc.) that block apt-get operations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl -s "${getProxmoxUrl()}" | bash`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
`curl -s "${getProxmoxUrl()}" | bash`,
|
||||
"curl-command",
|
||||
)
|
||||
}
|
||||
className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap"
|
||||
>
|
||||
{copy_success["curl-command"] ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
|
||||
💡 This command will automatically discover and enroll all
|
||||
running LXC containers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewToken(null);
|
||||
setShowSecret(false);
|
||||
}}
|
||||
className="flex-1 btn-primary py-2 px-4 rounded-md"
|
||||
>
|
||||
I've Saved the Credentials
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Code, Server } from "lucide-react";
|
||||
import { Code, Image, Server } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import SettingsLayout from "../../components/SettingsLayout";
|
||||
import BrandingTab from "../../components/settings/BrandingTab";
|
||||
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
|
||||
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
|
||||
|
||||
@@ -12,6 +13,7 @@ const SettingsServerConfig = () => {
|
||||
// Set initial tab based on current route
|
||||
if (location.pathname === "/settings/server-version") return "version";
|
||||
if (location.pathname === "/settings/server-url") return "protocol";
|
||||
if (location.pathname === "/settings/branding") return "branding";
|
||||
if (location.pathname === "/settings/server-config/version")
|
||||
return "version";
|
||||
return "protocol";
|
||||
@@ -23,6 +25,8 @@ const SettingsServerConfig = () => {
|
||||
setActiveTab("version");
|
||||
} else if (location.pathname === "/settings/server-url") {
|
||||
setActiveTab("protocol");
|
||||
} else if (location.pathname === "/settings/branding") {
|
||||
setActiveTab("branding");
|
||||
} else if (location.pathname === "/settings/server-config/version") {
|
||||
setActiveTab("version");
|
||||
} else if (location.pathname === "/settings/server-config") {
|
||||
@@ -37,6 +41,12 @@ const SettingsServerConfig = () => {
|
||||
icon: Server,
|
||||
href: "/settings/server-url",
|
||||
},
|
||||
{
|
||||
id: "branding",
|
||||
name: "Branding",
|
||||
icon: Image,
|
||||
href: "/settings/branding",
|
||||
},
|
||||
{
|
||||
id: "version",
|
||||
name: "Server Version",
|
||||
@@ -49,6 +59,8 @@ const SettingsServerConfig = () => {
|
||||
switch (activeTab) {
|
||||
case "protocol":
|
||||
return <ProtocolUrlTab />;
|
||||
case "branding":
|
||||
return <BrandingTab />;
|
||||
case "version":
|
||||
return <VersionUpdateTab />;
|
||||
default:
|
||||
|
||||
@@ -51,7 +51,16 @@ export const dashboardAPI = {
|
||||
getStats: () => api.get("/dashboard/stats"),
|
||||
getHosts: () => api.get("/dashboard/hosts"),
|
||||
getPackages: () => api.get("/dashboard/packages"),
|
||||
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
|
||||
getHostDetail: (hostId, params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = `/dashboard/hosts/${hostId}${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getPackageTrends: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = `/dashboard/package-trends${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getRecentUsers: () => api.get("/dashboard/recent-users"),
|
||||
getRecentCollection: () => api.get("/dashboard/recent-collection"),
|
||||
};
|
||||
@@ -132,6 +141,7 @@ export const repositoryAPI = {
|
||||
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
|
||||
update: (repositoryId, data) =>
|
||||
api.put(`/repositories/${repositoryId}`, data),
|
||||
delete: (repositoryId) => api.delete(`/repositories/${repositoryId}`),
|
||||
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
|
||||
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
|
||||
isEnabled,
|
||||
@@ -223,8 +233,8 @@ export const versionAPI = {
|
||||
export const authAPI = {
|
||||
login: (username, password) =>
|
||||
api.post("/auth/login", { username, password }),
|
||||
verifyTfa: (username, token) =>
|
||||
api.post("/auth/verify-tfa", { username, token }),
|
||||
verifyTfa: (username, token, remember_me = false) =>
|
||||
api.post("/auth/verify-tfa", { username, token, remember_me }),
|
||||
signup: (username, email, password, firstName, lastName) =>
|
||||
api.post("/auth/signup", {
|
||||
username,
|
||||
@@ -259,4 +269,9 @@ export const formatRelativeTime = (date) => {
|
||||
return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
|
||||
};
|
||||
|
||||
// Search API
|
||||
export const searchAPI = {
|
||||
global: (query) => api.get("/search", { params: { q: query } }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -24,8 +24,16 @@ export const getOSIcon = (osType) => {
|
||||
// Linux distributions with authentic react-icons
|
||||
if (os.includes("ubuntu")) return SiUbuntu;
|
||||
if (os.includes("debian")) return SiDebian;
|
||||
if (os.includes("centos") || os.includes("rhel") || os.includes("red hat"))
|
||||
if (
|
||||
os.includes("centos") ||
|
||||
os.includes("rhel") ||
|
||||
os.includes("red hat") ||
|
||||
os.includes("almalinux") ||
|
||||
os.includes("rocky")
|
||||
)
|
||||
return SiCentos;
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return SiLinux; // Use generic Linux icon for Oracle Linux
|
||||
if (os.includes("fedora")) return SiFedora;
|
||||
if (os.includes("arch")) return SiArchlinux;
|
||||
if (os.includes("alpine")) return SiAlpinelinux;
|
||||
@@ -72,6 +80,10 @@ export const getOSDisplayName = (osType) => {
|
||||
if (os.includes("ubuntu")) return "Ubuntu";
|
||||
if (os.includes("debian")) return "Debian";
|
||||
if (os.includes("centos")) return "CentOS";
|
||||
if (os.includes("almalinux")) return "AlmaLinux";
|
||||
if (os.includes("rocky")) return "Rocky Linux";
|
||||
if (os === "ol" || os.includes("oraclelinux") || os.includes("oracle linux"))
|
||||
return "Oracle Linux";
|
||||
if (os.includes("rhel") || os.includes("red hat"))
|
||||
return "Red Hat Enterprise Linux";
|
||||
if (os.includes("fedora")) return "Fedora";
|
||||
|
||||
@@ -43,5 +43,25 @@ export default defineConfig({
|
||||
outDir: "dist",
|
||||
sourcemap: process.env.NODE_ENV !== "production",
|
||||
target: "es2018",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// React core
|
||||
"react-vendor": ["react", "react-dom", "react-router-dom"],
|
||||
// Large utility libraries
|
||||
"utils-vendor": ["axios", "@tanstack/react-query", "date-fns"],
|
||||
// Chart libraries
|
||||
"chart-vendor": ["chart.js", "react-chartjs-2"],
|
||||
// Icon libraries
|
||||
"icons-vendor": ["lucide-react", "react-icons"],
|
||||
// DnD libraries
|
||||
"dnd-vendor": [
|
||||
"@dnd-kit/core",
|
||||
"@dnd-kit/sortable",
|
||||
"@dnd-kit/utilities",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon",
|
||||
"version": "1.2.7",
|
||||
"version": "1.2.8",
|
||||
"description": "Linux Patch Monitoring System",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
|
||||
491
setup.sh
491
setup.sh
@@ -34,8 +34,8 @@ BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Global variables
|
||||
SCRIPT_VERSION="self-hosting-install.sh v1.2.7-selfhost-2025-01-20-1"
|
||||
DEFAULT_GITHUB_REPO="https://github.com/9technologygroup/patchmon.net.git"
|
||||
SCRIPT_VERSION="self-hosting-install.sh v1.2.8-selfhost-2025-10-10-6"
|
||||
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
|
||||
FQDN=""
|
||||
CUSTOM_FQDN=""
|
||||
EMAIL=""
|
||||
@@ -60,6 +60,9 @@ SERVICE_USE_LETSENCRYPT="true" # Will be set based on user input
|
||||
SERVER_PROTOCOL_SEL="https"
|
||||
SERVER_PORT_SEL="" # Will be set to BACKEND_PORT in init_instance_vars
|
||||
SETUP_NGINX="true"
|
||||
UPDATE_MODE="false"
|
||||
SELECTED_INSTANCE=""
|
||||
SELECTED_SERVICE_NAME=""
|
||||
|
||||
# Functions
|
||||
print_status() {
|
||||
@@ -254,7 +257,7 @@ check_prerequisites() {
|
||||
}
|
||||
|
||||
select_branch() {
|
||||
print_info "Fetching available branches from GitHub repository..."
|
||||
print_info "Fetching available releases from GitHub repository..."
|
||||
|
||||
# Create temporary directory for git operations
|
||||
TEMP_DIR="/tmp/patchmon_branches_$$"
|
||||
@@ -263,84 +266,88 @@ select_branch() {
|
||||
|
||||
# Try to clone the repository normally
|
||||
if git clone "$DEFAULT_GITHUB_REPO" . 2>/dev/null; then
|
||||
# Get list of remote branches and trim whitespace
|
||||
branches=$(git branch -r | grep -v HEAD | sed 's/origin\///' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//' | sort -u)
|
||||
# Get list of tags sorted by version (semantic versioning)
|
||||
# Using git tag with version sorting
|
||||
tags=$(git tag -l --sort=-v:refname 2>/dev/null | head -3)
|
||||
|
||||
if [ -n "$branches" ]; then
|
||||
print_info "Available branches with details:"
|
||||
if [ -n "$tags" ]; then
|
||||
print_info "Available releases and branches:"
|
||||
echo ""
|
||||
|
||||
# Get branch information
|
||||
branch_count=1
|
||||
while IFS= read -r branch; do
|
||||
if [ -n "$branch" ]; then
|
||||
# Get last commit date for this branch
|
||||
last_commit=$(git log -1 --format="%ci" "origin/$branch" 2>/dev/null || echo "Unknown")
|
||||
|
||||
# Get release tag associated with this branch (if any)
|
||||
release_tag=$(git describe --tags --exact-match "origin/$branch" 2>/dev/null || echo "")
|
||||
# Display last 3 release tags
|
||||
option_count=1
|
||||
declare -A options_map
|
||||
|
||||
while IFS= read -r tag; do
|
||||
if [ -n "$tag" ]; then
|
||||
# Get tag date and commit info
|
||||
tag_date=$(git log -1 --format="%ci" "$tag" 2>/dev/null || echo "Unknown")
|
||||
|
||||
# Format the date
|
||||
if [ "$last_commit" != "Unknown" ]; then
|
||||
formatted_date=$(date -d "$last_commit" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$last_commit")
|
||||
if [ "$tag_date" != "Unknown" ]; then
|
||||
formatted_date=$(date -d "$tag_date" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$tag_date")
|
||||
else
|
||||
formatted_date="Unknown"
|
||||
fi
|
||||
|
||||
# Display branch info
|
||||
printf "%2d. %-20s" "$branch_count" "$branch"
|
||||
printf " (Last commit: %s)" "$formatted_date"
|
||||
|
||||
if [ -n "$release_tag" ]; then
|
||||
printf " [Release: %s]" "$release_tag"
|
||||
# Mark the first one as latest
|
||||
if [ $option_count -eq 1 ]; then
|
||||
printf "%2d. %-20s (Latest Release - %s)\n" "$option_count" "$tag" "$formatted_date"
|
||||
else
|
||||
printf "%2d. %-20s (Release - %s)\n" "$option_count" "$tag" "$formatted_date"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
branch_count=$((branch_count + 1))
|
||||
# Store the tag for later selection
|
||||
options_map[$option_count]="$tag"
|
||||
option_count=$((option_count + 1))
|
||||
fi
|
||||
done <<< "$branches"
|
||||
done <<< "$tags"
|
||||
|
||||
# Add main branch as an option
|
||||
main_commit=$(git log -1 --format="%ci" "origin/main" 2>/dev/null || echo "Unknown")
|
||||
if [ "$main_commit" != "Unknown" ]; then
|
||||
formatted_main_date=$(date -d "$main_commit" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$main_commit")
|
||||
else
|
||||
formatted_main_date="Unknown"
|
||||
fi
|
||||
printf "%2d. %-20s (Development Branch - %s)\n" "$option_count" "main" "$formatted_main_date"
|
||||
options_map[$option_count]="main"
|
||||
|
||||
echo ""
|
||||
|
||||
# Determine default selection: prefer 'main' if present
|
||||
main_index=$(echo "$branches" | nl -w1 -s':' | awk -F':' '$2=="main"{print $1}' | head -1)
|
||||
if [ -z "$main_index" ]; then
|
||||
main_index=1
|
||||
fi
|
||||
# Default to option 1 (latest release tag)
|
||||
default_option=1
|
||||
|
||||
while true; do
|
||||
read_input "Select branch number" BRANCH_NUMBER "$main_index"
|
||||
read_input "Select version/branch number" SELECTION_NUMBER "$default_option"
|
||||
|
||||
if [[ "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
selected_branch=$(echo "$branches" | sed -n "${BRANCH_NUMBER}p" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
|
||||
if [ -n "$selected_branch" ]; then
|
||||
DEPLOYMENT_BRANCH="$selected_branch"
|
||||
if [[ "$SELECTION_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
selected_option="${options_map[$SELECTION_NUMBER]}"
|
||||
if [ -n "$selected_option" ]; then
|
||||
DEPLOYMENT_BRANCH="$selected_option"
|
||||
|
||||
# Show additional info for selected branch
|
||||
last_commit=$(git log -1 --format="%ci" "origin/$selected_branch" 2>/dev/null || echo "Unknown")
|
||||
release_tag=$(git describe --tags --exact-match "origin/$selected_branch" 2>/dev/null || echo "")
|
||||
|
||||
if [ "$last_commit" != "Unknown" ]; then
|
||||
formatted_date=$(date -d "$last_commit" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$last_commit")
|
||||
# Show confirmation
|
||||
if [ "$selected_option" = "main" ]; then
|
||||
print_status "Selected branch: main (latest development code)"
|
||||
print_info "Last commit: $formatted_main_date"
|
||||
else
|
||||
formatted_date="Unknown"
|
||||
fi
|
||||
|
||||
print_status "Selected branch: $DEPLOYMENT_BRANCH"
|
||||
print_info "Last commit: $formatted_date"
|
||||
if [ -n "$release_tag" ]; then
|
||||
print_info "Release tag: $release_tag"
|
||||
print_status "Selected release: $selected_option"
|
||||
tag_date=$(git log -1 --format="%ci" "$selected_option" 2>/dev/null || echo "Unknown")
|
||||
if [ "$tag_date" != "Unknown" ]; then
|
||||
formatted_date=$(date -d "$tag_date" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$tag_date")
|
||||
print_info "Release date: $formatted_date"
|
||||
fi
|
||||
fi
|
||||
break
|
||||
else
|
||||
print_error "Invalid branch number. Please try again."
|
||||
print_error "Invalid selection number. Please try again."
|
||||
fi
|
||||
else
|
||||
print_error "Please enter a valid number."
|
||||
fi
|
||||
done
|
||||
else
|
||||
print_warning "No branches found, using default: main"
|
||||
print_warning "No release tags found, using default: main"
|
||||
DEPLOYMENT_BRANCH="main"
|
||||
fi
|
||||
else
|
||||
@@ -638,31 +645,61 @@ EOF
|
||||
|
||||
# Setup database for instance
|
||||
setup_database() {
|
||||
print_info "Creating database: $DB_NAME"
|
||||
print_info "Setting up database: $DB_NAME"
|
||||
|
||||
# Check if sudo is available for user switching
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
# Drop and recreate database and user for clean state
|
||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;" || true
|
||||
sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;" || true
|
||||
# Check if user exists
|
||||
user_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" || echo "0")
|
||||
|
||||
# Create database and user
|
||||
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
|
||||
sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
|
||||
if [ "$user_exists" = "1" ]; then
|
||||
print_info "Database user $DB_USER already exists, skipping creation"
|
||||
else
|
||||
print_info "Creating database user $DB_USER"
|
||||
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
|
||||
fi
|
||||
|
||||
# Check if database exists
|
||||
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" || echo "0")
|
||||
|
||||
if [ "$db_exists" = "1" ]; then
|
||||
print_info "Database $DB_NAME already exists, skipping creation"
|
||||
else
|
||||
print_info "Creating database $DB_NAME"
|
||||
sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
|
||||
fi
|
||||
|
||||
# Always grant privileges (in case they were revoked)
|
||||
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
|
||||
else
|
||||
# Alternative method for systems without sudo (run as postgres user directly)
|
||||
print_warning "sudo not available, using alternative method for PostgreSQL setup"
|
||||
|
||||
# Switch to postgres user using su
|
||||
su - postgres -c "psql -c \"DROP DATABASE IF EXISTS $DB_NAME;\"" || true
|
||||
su - postgres -c "psql -c \"DROP USER IF EXISTS $DB_USER;\"" || true
|
||||
su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\""
|
||||
su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\""
|
||||
# Check if user exists
|
||||
user_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" || echo "0")
|
||||
|
||||
if [ "$user_exists" = "1" ]; then
|
||||
print_info "Database user $DB_USER already exists, skipping creation"
|
||||
else
|
||||
print_info "Creating database user $DB_USER"
|
||||
su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\""
|
||||
fi
|
||||
|
||||
# Check if database exists
|
||||
db_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" || echo "0")
|
||||
|
||||
if [ "$db_exists" = "1" ]; then
|
||||
print_info "Database $DB_NAME already exists, skipping creation"
|
||||
else
|
||||
print_info "Creating database $DB_NAME"
|
||||
su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\""
|
||||
fi
|
||||
|
||||
# Always grant privileges (in case they were revoked)
|
||||
su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;\""
|
||||
fi
|
||||
|
||||
print_status "Database $DB_NAME created with user $DB_USER"
|
||||
print_status "Database setup complete for $DB_NAME"
|
||||
}
|
||||
|
||||
# Clone application repository
|
||||
@@ -789,9 +826,13 @@ create_env_files() {
|
||||
cat > backend/.env << EOF
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
|
||||
PM_DB_CONN_MAX_ATTEMPTS=30
|
||||
PM_DB_CONN_WAIT_INTERVAL=2
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET="$JWT_SECRET"
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Server Configuration
|
||||
PORT=$BACKEND_PORT
|
||||
@@ -803,6 +844,12 @@ API_VERSION=v1
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN="$SERVER_PROTOCOL_SEL://$FQDN"
|
||||
|
||||
# Session Configuration
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
# User Configuration
|
||||
DEFAULT_USER_ROLE=user
|
||||
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX=5000
|
||||
@@ -813,13 +860,14 @@ AGENT_RATE_LIMIT_MAX=1000
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
ENABLE_LOGGING=true
|
||||
EOF
|
||||
|
||||
# Frontend .env
|
||||
cat > frontend/.env << EOF
|
||||
VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1
|
||||
VITE_APP_NAME=PatchMon
|
||||
VITE_APP_VERSION=1.2.7
|
||||
VITE_APP_VERSION=1.2.8
|
||||
EOF
|
||||
|
||||
print_status "Environment files created"
|
||||
@@ -1191,7 +1239,7 @@ create_agent_version() {
|
||||
|
||||
# Priority 2: Use fallback version if not found
|
||||
if [ "$current_version" = "N/A" ] || [ -z "$current_version" ]; then
|
||||
current_version="1.2.7"
|
||||
current_version="1.2.8"
|
||||
print_warning "Could not determine version, using fallback: $current_version"
|
||||
fi
|
||||
|
||||
@@ -1535,11 +1583,287 @@ deploy_instance() {
|
||||
:
|
||||
}
|
||||
|
||||
# Detect existing PatchMon installations
|
||||
detect_installations() {
|
||||
local installations=()
|
||||
|
||||
# Find all directories in /opt that contain PatchMon installations
|
||||
if [ -d "/opt" ]; then
|
||||
for dir in /opt/*/; do
|
||||
local dirname=$(basename "$dir")
|
||||
# Skip backup directories
|
||||
if [[ "$dirname" =~ \.backup\. ]]; then
|
||||
continue
|
||||
fi
|
||||
# Check if it's a PatchMon installation
|
||||
if [ -f "$dir/backend/package.json" ] && grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then
|
||||
installations+=("$dirname")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "${installations[@]}"
|
||||
}
|
||||
|
||||
# Select installation to update
|
||||
select_installation_to_update() {
|
||||
local installations=($(detect_installations))
|
||||
|
||||
if [ ${#installations[@]} -eq 0 ]; then
|
||||
print_error "No existing PatchMon installations found in /opt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_info "Found ${#installations[@]} existing installation(s):"
|
||||
echo ""
|
||||
|
||||
local i=1
|
||||
declare -A install_map
|
||||
for install in "${installations[@]}"; do
|
||||
# Get current version if possible
|
||||
local version="unknown"
|
||||
if [ -f "/opt/$install/backend/package.json" ]; then
|
||||
version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
|
||||
fi
|
||||
|
||||
# Get service status - try multiple naming conventions
|
||||
# Convention 1: Just the install name (e.g., patchmon.internal)
|
||||
local service_name="$install"
|
||||
# Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal)
|
||||
local alt_service_name1="patchmon.$install"
|
||||
# Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal)
|
||||
local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')"
|
||||
local status="unknown"
|
||||
|
||||
# Try convention 1 first (most common)
|
||||
if systemctl is-active --quiet "$service_name" 2>/dev/null; then
|
||||
status="running"
|
||||
elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
|
||||
status="stopped"
|
||||
# Try convention 2
|
||||
elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then
|
||||
status="running"
|
||||
service_name="$alt_service_name1"
|
||||
elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then
|
||||
status="stopped"
|
||||
service_name="$alt_service_name1"
|
||||
# Try convention 3
|
||||
elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then
|
||||
status="running"
|
||||
service_name="$alt_service_name2"
|
||||
elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then
|
||||
status="stopped"
|
||||
service_name="$alt_service_name2"
|
||||
fi
|
||||
|
||||
printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
|
||||
install_map[$i]="$install"
|
||||
# Store the service name for later use
|
||||
declare -g "service_map_$i=$service_name"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
read_input "Select installation number to update" SELECTION "1"
|
||||
|
||||
if [[ "$SELECTION" =~ ^[0-9]+$ ]] && [ -n "${install_map[$SELECTION]}" ]; then
|
||||
SELECTED_INSTANCE="${install_map[$SELECTION]}"
|
||||
# Get the stored service name
|
||||
local varname="service_map_$SELECTION"
|
||||
SELECTED_SERVICE_NAME="${!varname}"
|
||||
print_status "Selected: $SELECTED_INSTANCE"
|
||||
print_info "Service: $SELECTED_SERVICE_NAME"
|
||||
return 0
|
||||
else
|
||||
print_error "Invalid selection. Please enter a number from 1 to ${#installations[@]}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Update existing installation
|
||||
update_installation() {
|
||||
local instance_dir="/opt/$SELECTED_INSTANCE"
|
||||
local service_name="$SELECTED_SERVICE_NAME"
|
||||
|
||||
print_info "Updating PatchMon installation: $SELECTED_INSTANCE"
|
||||
print_info "Installation directory: $instance_dir"
|
||||
print_info "Service name: $service_name"
|
||||
|
||||
# Verify it's a git repository
|
||||
if [ ! -d "$instance_dir/.git" ]; then
|
||||
print_error "Installation directory is not a git repository"
|
||||
print_error "Cannot perform git-based update"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add git safe.directory to avoid ownership issues when running as root
|
||||
print_info "Configuring git safe.directory..."
|
||||
git config --global --add safe.directory "$instance_dir" 2>/dev/null || true
|
||||
|
||||
# Load existing .env to get database credentials
|
||||
if [ -f "$instance_dir/backend/.env" ]; then
|
||||
source "$instance_dir/backend/.env"
|
||||
print_status "Loaded existing configuration"
|
||||
|
||||
# Parse DATABASE_URL to extract credentials
|
||||
# Format: postgresql://user:password@host:port/database
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
# Extract components using regex
|
||||
DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p')
|
||||
DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p')
|
||||
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p')
|
||||
DB_PORT=$(echo "$DATABASE_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
|
||||
DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p')
|
||||
|
||||
print_info "Database: $DB_NAME (user: $DB_USER)"
|
||||
else
|
||||
print_error "DATABASE_URL not found in .env file"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
print_error "Cannot find .env file at $instance_dir/backend/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Select branch/version to update to
|
||||
select_branch
|
||||
|
||||
print_info "Updating to: $DEPLOYMENT_BRANCH"
|
||||
echo ""
|
||||
|
||||
read_yes_no "Proceed with update? This will pull new code and restart services" CONFIRM_UPDATE "y"
|
||||
|
||||
if [ "$CONFIRM_UPDATE" != "y" ]; then
|
||||
print_warning "Update cancelled by user"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stop the service
|
||||
print_info "Stopping service: $service_name"
|
||||
systemctl stop "$service_name" || true
|
||||
|
||||
# Create backup directory
|
||||
local timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_dir="$instance_dir.backup.$timestamp"
|
||||
local db_backup_file="$backup_dir/database_backup_$timestamp.sql"
|
||||
|
||||
print_info "Creating backup directory: $backup_dir"
|
||||
mkdir -p "$backup_dir"
|
||||
|
||||
# Backup database
|
||||
print_info "Backing up database: $DB_NAME"
|
||||
if PGPASSWORD="$DB_PASS" pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -F c -f "$db_backup_file" 2>/dev/null; then
|
||||
print_status "Database backup created: $db_backup_file"
|
||||
else
|
||||
print_warning "Database backup failed, but continuing with code backup"
|
||||
fi
|
||||
|
||||
# Backup code
|
||||
print_info "Backing up code files..."
|
||||
cp -r "$instance_dir" "$backup_dir/code"
|
||||
print_status "Code backup created"
|
||||
|
||||
# Update code
|
||||
print_info "Pulling latest code from branch: $DEPLOYMENT_BRANCH"
|
||||
cd "$instance_dir"
|
||||
|
||||
# Fetch latest changes
|
||||
git fetch origin
|
||||
|
||||
# Checkout the selected branch/tag
|
||||
git checkout "$DEPLOYMENT_BRANCH"
|
||||
git pull origin "$DEPLOYMENT_BRANCH" || git pull # For tags, just pull
|
||||
|
||||
print_status "Code updated successfully"
|
||||
|
||||
# Update dependencies
|
||||
print_info "Updating backend dependencies..."
|
||||
cd "$instance_dir/backend"
|
||||
npm install --production --ignore-scripts
|
||||
|
||||
print_info "Updating frontend dependencies..."
|
||||
cd "$instance_dir/frontend"
|
||||
npm install --ignore-scripts
|
||||
|
||||
# Build frontend
|
||||
print_info "Building frontend..."
|
||||
npm run build
|
||||
|
||||
# Run database migrations and generate Prisma client
|
||||
print_info "Running database migrations..."
|
||||
cd "$instance_dir/backend"
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Start the service
|
||||
print_info "Starting service: $service_name"
|
||||
systemctl start "$service_name"
|
||||
|
||||
# Wait a moment and check status
|
||||
sleep 3
|
||||
|
||||
if systemctl is-active --quiet "$service_name"; then
|
||||
print_success "✅ Update completed successfully!"
|
||||
print_status "Service $service_name is running"
|
||||
|
||||
# Get new version
|
||||
local new_version=$(grep '"version"' "$instance_dir/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
|
||||
print_info "Updated to version: $new_version"
|
||||
echo ""
|
||||
print_info "Backup Information:"
|
||||
print_info " Code backup: $backup_dir/code"
|
||||
print_info " Database backup: $db_backup_file"
|
||||
echo ""
|
||||
print_info "To restore database if needed:"
|
||||
print_info " PGPASSWORD=\"$DB_PASS\" pg_restore -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"$db_backup_file\""
|
||||
echo ""
|
||||
else
|
||||
print_error "Service failed to start after update"
|
||||
echo ""
|
||||
print_warning "ROLLBACK INSTRUCTIONS:"
|
||||
print_info "1. Restore code:"
|
||||
print_info " sudo rm -rf $instance_dir"
|
||||
print_info " sudo mv $backup_dir/code $instance_dir"
|
||||
echo ""
|
||||
print_info "2. Restore database:"
|
||||
print_info " PGPASSWORD=\"$DB_PASS\" pg_restore -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"$db_backup_file\""
|
||||
echo ""
|
||||
print_info "3. Restart service:"
|
||||
print_info " sudo systemctl start $service_name"
|
||||
echo ""
|
||||
print_info "Check logs: journalctl -u $service_name -f"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main script execution
|
||||
main() {
|
||||
# Log script entry
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Interactive installation started" >> "$DEBUG_LOG"
|
||||
# Parse command-line arguments
|
||||
if [ "$1" = "--update" ]; then
|
||||
UPDATE_MODE="true"
|
||||
fi
|
||||
|
||||
# Log script entry
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script started - Update mode: $UPDATE_MODE" >> "$DEBUG_LOG"
|
||||
|
||||
# Handle update mode
|
||||
if [ "$UPDATE_MODE" = "true" ]; then
|
||||
print_banner
|
||||
print_info "🔄 PatchMon Update Mode"
|
||||
echo ""
|
||||
|
||||
# Select installation to update
|
||||
select_installation_to_update
|
||||
|
||||
# Perform update
|
||||
update_installation
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Normal installation mode
|
||||
# Run interactive setup
|
||||
interactive_setup
|
||||
|
||||
@@ -1573,5 +1897,30 @@ main() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] deploy_instance function completed" >> "$DEBUG_LOG"
|
||||
}
|
||||
|
||||
# Run main function (no arguments needed for interactive mode)
|
||||
main
|
||||
# Show usage/help
|
||||
show_usage() {
|
||||
echo "PatchMon Self-Hosting Installation & Update Script"
|
||||
echo "Version: $SCRIPT_VERSION"
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " $0 # Interactive installation (default)"
|
||||
echo " $0 --update # Update existing installation"
|
||||
echo " $0 --help # Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " # New installation:"
|
||||
echo " sudo bash $0"
|
||||
echo ""
|
||||
echo " # Update existing installation:"
|
||||
echo " sudo bash $0 --update"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Check for help flag
|
||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user