mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-04 22:13:21 +00:00
Compare commits
73 Commits
v1.2.7
...
ci/docker_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aab6fc244e | ||
|
|
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 |
10
.github/workflows/app_build.yml
vendored
10
.github/workflows/app_build.yml
vendored
@@ -1,11 +1,9 @@
|
|||||||
name: Build on Merge
|
name: Build on Merge
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
@@ -15,3 +13,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Run rebuild script
|
- name: Run rebuild script
|
||||||
run: /root/patchmon/platform/scripts/app_build.sh ${{ github.ref_name }}
|
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
|
||||||
|
|||||||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
@@ -64,7 +64,11 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: docker/${{ matrix.image }}.Dockerfile
|
file: docker/${{ matrix.image }}.Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
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 }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha,scope=${{ matrix.image }}
|
cache-from: type=gha,scope=${{ matrix.image }}
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -71,6 +71,13 @@ jspm_packages/
|
|||||||
.cache/
|
.cache/
|
||||||
public
|
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
|
# Storybook build outputs
|
||||||
.out
|
.out
|
||||||
.storybook-out
|
.storybook-out
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -4,6 +4,8 @@
|
|||||||
[](https://patchmon.net/discord)
|
[](https://patchmon.net/discord)
|
||||||
[](https://github.com/9technologygroup/patchmon.net)
|
[](https://github.com/9technologygroup/patchmon.net)
|
||||||
[](https://github.com/users/9technologygroup/projects/1)
|
[](https://github.com/users/9technologygroup/projects/1)
|
||||||
|
[](https://docs.patchmon.net/)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Please STAR this repo :D
|
## 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.
|
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
|
## Features
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ PatchMon provides centralized patch management across diverse server environment
|
|||||||
|
|
||||||
### API & Integrations
|
### API & Integrations
|
||||||
- REST API under `/api/v1` with JWT auth
|
- REST API under `/api/v1` with JWT auth
|
||||||
|
- **Proxmox LXC Auto-Enrollment** - Automatically discover and enroll LXC containers from Proxmox hosts ([Documentation](PROXMOX_AUTO_ENROLLMENT.md))
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- Rate limiting for general, auth, and agent endpoints
|
- Rate limiting for general, auth, and agent endpoints
|
||||||
@@ -62,7 +65,7 @@ Managed, zero-maintenance PatchMon hosting. Stay tuned.
|
|||||||
|
|
||||||
#### Docker (preferred)
|
#### 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)
|
#### Native Install (advanced/non-docker)
|
||||||
|
|
||||||
@@ -84,7 +87,7 @@ apt install curl -y
|
|||||||
|
|
||||||
#### Script
|
#### Script
|
||||||
```bash
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Minimum specs for building : #####
|
#### Minimum specs for building : #####
|
||||||
@@ -124,22 +127,18 @@ After installation:
|
|||||||
- Database: PostgreSQL
|
- Database: PostgreSQL
|
||||||
- System service: systemd-managed backend
|
- 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
|
Operational
|
||||||
- systemd manages backend service
|
- systemd manages backend service
|
||||||
- certbot/nginx for TLS (public)
|
- certbot/nginx for TLS (public)
|
||||||
- setup.sh bootstraps OS, app, DB, config
|
- setup.sh bootstraps OS, app, DB, config
|
||||||
```
|
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
@@ -148,7 +147,7 @@ Operational
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- Roadmap board: https://github.com/users/9technologygroup/projects/1
|
- Roadmap board: https://github.com/orgs/PatchMon/projects/2
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
@@ -271,7 +270,7 @@ Thank you to all our contributors who help make PatchMon better every day!
|
|||||||
- **Website**: [patchmon.net](https://patchmon.net)
|
- **Website**: [patchmon.net](https://patchmon.net)
|
||||||
- **Discord**: [https://patchmon.net/discord](https://patchmon.net/discord)
|
- **Discord**: [https://patchmon.net/discord](https://patchmon.net/discord)
|
||||||
- **Roadmap**: [GitHub Projects](https://github.com/users/9technologygroup/projects/1)
|
- **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
|
- **Support**: support@patchmon.net
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -281,6 +280,6 @@ Thank you to all our contributors who help make PatchMon better every day!
|
|||||||
**Made with ❤️ by the PatchMon Team**
|
**Made with ❤️ by the PatchMon Team**
|
||||||
|
|
||||||
[](https://patchmon.net/discord)
|
[](https://patchmon.net/discord)
|
||||||
[](https://github.com/9technologygroup/patchmon.net)
|
[](https://github.com/PatchMon/PatchMon)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,28 @@ warning() {
|
|||||||
log "WARNING: $1"
|
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 if running as root
|
||||||
check_root() {
|
check_root() {
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
@@ -686,7 +708,7 @@ get_yum_packages() {
|
|||||||
done <<< "$upgradable"
|
done <<< "$upgradable"
|
||||||
|
|
||||||
# Get some installed packages that are up to date
|
# 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
|
while IFS= read -r line; do
|
||||||
# Skip empty lines
|
# Skip empty lines
|
||||||
@@ -865,6 +887,9 @@ send_update() {
|
|||||||
|
|
||||||
# Merge all JSON objects into one
|
# Merge all JSON objects into one
|
||||||
local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]')
|
local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]')
|
||||||
|
# Get machine ID
|
||||||
|
local machine_id=$(get_machine_id)
|
||||||
|
|
||||||
# Create the base payload and merge with system info
|
# Create the base payload and merge with system info
|
||||||
local base_payload=$(cat <<EOF
|
local base_payload=$(cat <<EOF
|
||||||
{
|
{
|
||||||
@@ -875,7 +900,8 @@ send_update() {
|
|||||||
"hostname": "$HOSTNAME",
|
"hostname": "$HOSTNAME",
|
||||||
"ip": "$IP_ADDRESS",
|
"ip": "$IP_ADDRESS",
|
||||||
"architecture": "$ARCHITECTURE",
|
"architecture": "$ARCHITECTURE",
|
||||||
"agentVersion": "$AGENT_VERSION"
|
"agentVersion": "$AGENT_VERSION",
|
||||||
|
"machineId": "$machine_id"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -109,14 +109,39 @@ cleanup_old_files() {
|
|||||||
# Run cleanup at start
|
# Run cleanup at start
|
||||||
cleanup_old_files
|
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)
|
# Parse arguments from environment (passed via HTTP headers)
|
||||||
if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then
|
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."
|
error "Missing required parameters. This script should be called via the PatchMon web interface."
|
||||||
fi
|
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 "🚀 Starting PatchMon Agent Installation..."
|
||||||
info "📋 Server: $PATCHMON_URL"
|
info "📋 Server: $PATCHMON_URL"
|
||||||
info "🔑 API ID: ${API_ID:0:16}..."
|
info "🔑 API ID: ${API_ID:0:16}..."
|
||||||
|
info "🆔 Machine ID: ${MACHINE_ID:0:16}..."
|
||||||
|
|
||||||
# Display diagnostic information
|
# Display diagnostic information
|
||||||
echo ""
|
echo ""
|
||||||
@@ -131,16 +156,88 @@ echo ""
|
|||||||
info "📦 Installing required dependencies..."
|
info "📦 Installing required dependencies..."
|
||||||
echo ""
|
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
|
# Detect package manager and install jq and curl
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
# Debian/Ubuntu
|
# Debian/Ubuntu
|
||||||
info "Detected apt-get (Debian/Ubuntu)"
|
info "Detected apt-get (Debian/Ubuntu)"
|
||||||
echo ""
|
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..."
|
info "Updating package lists..."
|
||||||
apt-get update
|
apt-get update || true
|
||||||
echo ""
|
echo ""
|
||||||
info "Installing jq, curl, and bc..."
|
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
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
# CentOS/RHEL 7
|
# CentOS/RHEL 7
|
||||||
info "Detected yum (CentOS/RHEL 7)"
|
info "Detected yum (CentOS/RHEL 7)"
|
||||||
@@ -261,6 +358,33 @@ if [[ -f "/var/log/patchmon-agent.log" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 4: Test the configuration
|
# 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..."
|
info "🧪 Testing API credentials and connectivity..."
|
||||||
if /usr/local/bin/patchmon-agent.sh test; then
|
if /usr/local/bin/patchmon-agent.sh test; then
|
||||||
success "✅ TEST: API credentials are valid and server is reachable"
|
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 Configuration
|
||||||
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
|
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
|
# Server Configuration
|
||||||
PORT=3001
|
PORT=3001
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -21,13 +21,14 @@ model dashboard_preferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model host_groups {
|
model host_groups {
|
||||||
id String @id
|
id String @id
|
||||||
name String @unique
|
name String @unique
|
||||||
description String?
|
description String?
|
||||||
color String? @default("#3B82F6")
|
color String? @default("#3B82F6")
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime
|
updated_at DateTime
|
||||||
hosts hosts[]
|
hosts hosts[]
|
||||||
|
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model host_packages {
|
model host_packages {
|
||||||
@@ -59,7 +60,8 @@ model host_repositories {
|
|||||||
|
|
||||||
model hosts {
|
model hosts {
|
||||||
id String @id
|
id String @id
|
||||||
friendly_name String @unique
|
machine_id String @unique
|
||||||
|
friendly_name String
|
||||||
ip String?
|
ip String?
|
||||||
os_type String
|
os_type String
|
||||||
os_version String
|
os_version String
|
||||||
@@ -91,6 +93,10 @@ model hosts {
|
|||||||
host_repositories host_repositories[]
|
host_repositories host_repositories[]
|
||||||
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
|
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
|
||||||
update_history update_history[]
|
update_history update_history[]
|
||||||
|
|
||||||
|
@@index([machine_id])
|
||||||
|
@@index([friendly_name])
|
||||||
|
@@index([hostname])
|
||||||
}
|
}
|
||||||
|
|
||||||
model packages {
|
model packages {
|
||||||
@@ -158,6 +164,9 @@ model settings {
|
|||||||
signup_enabled Boolean @default(false)
|
signup_enabled Boolean @default(false)
|
||||||
default_user_role String @default("user")
|
default_user_role String @default("user")
|
||||||
ignore_ssl_self_signed Boolean @default(false)
|
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 {
|
model update_history {
|
||||||
@@ -172,22 +181,23 @@ model update_history {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model users {
|
model users {
|
||||||
id String @id
|
id String @id
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
password_hash String
|
password_hash String
|
||||||
role String @default("admin")
|
role String @default("admin")
|
||||||
is_active Boolean @default(true)
|
is_active Boolean @default(true)
|
||||||
last_login DateTime?
|
last_login DateTime?
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime
|
updated_at DateTime
|
||||||
tfa_backup_codes String?
|
tfa_backup_codes String?
|
||||||
tfa_enabled Boolean @default(false)
|
tfa_enabled Boolean @default(false)
|
||||||
tfa_secret String?
|
tfa_secret String?
|
||||||
first_name String?
|
first_name String?
|
||||||
last_name String?
|
last_name String?
|
||||||
dashboard_preferences dashboard_preferences[]
|
dashboard_preferences dashboard_preferences[]
|
||||||
user_sessions user_sessions[]
|
user_sessions user_sessions[]
|
||||||
|
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model user_sessions {
|
model user_sessions {
|
||||||
@@ -207,3 +217,27 @@ model user_sessions {
|
|||||||
@@index([refresh_token])
|
@@index([refresh_token])
|
||||||
@@index([expires_at])
|
@@index([expires_at])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify token
|
// Verify token
|
||||||
const decoded = jwt.verify(
|
if (!process.env.JWT_SECRET) {
|
||||||
token,
|
throw new Error("JWT_SECRET environment variable is required");
|
||||||
process.env.JWT_SECRET || "your-secret-key",
|
}
|
||||||
);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
// Validate session and check inactivity timeout
|
// Validate session and check inactivity timeout
|
||||||
const validation = await validate_session(decoded.sessionId, token);
|
const validation = await validate_session(decoded.sessionId, token);
|
||||||
@@ -85,10 +85,10 @@ const optionalAuth = async (req, _res, next) => {
|
|||||||
const token = authHeader?.split(" ")[1];
|
const token = authHeader?.split(" ")[1];
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const decoded = jwt.verify(
|
if (!process.env.JWT_SECRET) {
|
||||||
token,
|
throw new Error("JWT_SECRET environment variable is required");
|
||||||
process.env.JWT_SECRET || "your-secret-key",
|
}
|
||||||
);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
const user = await prisma.users.findUnique({
|
const user = await prisma.users.findUnique({
|
||||||
where: { id: decoded.userId },
|
where: { id: decoded.userId },
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ router.post(
|
|||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
const generateToken = (userId) => {
|
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",
|
expiresIn: process.env.JWT_EXPIRES_IN || "24h",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -173,6 +176,8 @@ router.get(
|
|||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
role: true,
|
role: true,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
last_login: true,
|
last_login: true,
|
||||||
@@ -311,6 +316,14 @@ router.put(
|
|||||||
.isLength({ min: 3 })
|
.isLength({ min: 3 })
|
||||||
.withMessage("Username must be at least 3 characters"),
|
.withMessage("Username must be at least 3 characters"),
|
||||||
body("email").optional().isEmail().withMessage("Valid email is required"),
|
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")
|
body("role")
|
||||||
.optional()
|
.optional()
|
||||||
.custom(async (value) => {
|
.custom(async (value) => {
|
||||||
@@ -323,10 +336,10 @@ router.put(
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
body("isActive")
|
body("is_active")
|
||||||
.optional()
|
.optional()
|
||||||
.isBoolean()
|
.isBoolean()
|
||||||
.withMessage("isActive must be a boolean"),
|
.withMessage("is_active must be a boolean"),
|
||||||
],
|
],
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -337,13 +350,16 @@ router.put(
|
|||||||
return res.status(400).json({ errors: errors.array() });
|
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 = {};
|
const updateData = {};
|
||||||
|
|
||||||
if (username) updateData.username = username;
|
if (username) updateData.username = username;
|
||||||
if (email) updateData.email = email;
|
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 (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
|
// Check if user exists
|
||||||
const existingUser = await prisma.users.findUnique({
|
const existingUser = await prisma.users.findUnique({
|
||||||
@@ -378,7 +394,7 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prevent deactivating the last admin
|
// Prevent deactivating the last admin
|
||||||
if (isActive === false && existingUser.role === "admin") {
|
if (is_active === false && existingUser.role === "admin") {
|
||||||
const adminCount = await prisma.users.count({
|
const adminCount = await prisma.users.count({
|
||||||
where: {
|
where: {
|
||||||
role: "admin",
|
role: "admin",
|
||||||
@@ -401,6 +417,8 @@ router.put(
|
|||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
role: true,
|
role: true,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
last_login: true,
|
last_login: true,
|
||||||
|
|||||||
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;
|
||||||
@@ -185,6 +185,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
|||||||
// Show all hosts regardless of status
|
// Show all hosts regardless of status
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
machine_id: true,
|
||||||
friendly_name: true,
|
friendly_name: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
ip: true,
|
ip: true,
|
||||||
|
|||||||
@@ -172,15 +172,6 @@ router.post(
|
|||||||
// Generate unique API credentials for this host
|
// Generate unique API credentials for this host
|
||||||
const { apiId, apiKey } = generateApiCredentials();
|
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 is provided, verify the group exists
|
||||||
if (hostGroupId) {
|
if (hostGroupId) {
|
||||||
const hostGroup = await prisma.host_groups.findUnique({
|
const hostGroup = await prisma.host_groups.findUnique({
|
||||||
@@ -196,6 +187,7 @@ router.post(
|
|||||||
const host = await prisma.hosts.create({
|
const host = await prisma.hosts.create({
|
||||||
data: {
|
data: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
|
machine_id: `pending-${uuidv4()}`, // Temporary placeholder until agent connects with real machine_id
|
||||||
friendly_name: friendly_name,
|
friendly_name: friendly_name,
|
||||||
os_type: "unknown", // Will be updated when agent connects
|
os_type: "unknown", // Will be updated when agent connects
|
||||||
os_version: "unknown", // Will be updated when agent connects
|
os_version: "unknown", // Will be updated when agent connects
|
||||||
@@ -321,6 +313,10 @@ router.post(
|
|||||||
.optional()
|
.optional()
|
||||||
.isArray()
|
.isArray()
|
||||||
.withMessage("Load average must be an array"),
|
.withMessage("Load average must be an array"),
|
||||||
|
body("machineId")
|
||||||
|
.optional()
|
||||||
|
.isString()
|
||||||
|
.withMessage("Machine ID must be a string"),
|
||||||
],
|
],
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -338,6 +334,11 @@ router.post(
|
|||||||
updated_at: 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
|
// Basic system info
|
||||||
if (req.body.osType) updateData.os_type = req.body.osType;
|
if (req.body.osType) updateData.os_type = req.body.osType;
|
||||||
if (req.body.osVersion) updateData.os_version = req.body.osVersion;
|
if (req.body.osVersion) updateData.os_version = req.body.osVersion;
|
||||||
@@ -1126,12 +1127,16 @@ router.get("/install", async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} 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
|
const envVars = `#!/bin/bash
|
||||||
export PATCHMON_URL="${serverUrl}"
|
export PATCHMON_URL="${serverUrl}"
|
||||||
export API_ID="${host.api_id}"
|
export API_ID="${host.api_id}"
|
||||||
export API_KEY="${host.api_key}"
|
export API_KEY="${host.api_key}"
|
||||||
export CURL_FLAGS="${curlFlags}"
|
export CURL_FLAGS="${curlFlags}"
|
||||||
|
export FORCE_INSTALL="${forceInstall ? "true" : "false"}"
|
||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1151,6 +1156,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)
|
// Serve the removal script (public endpoint - no authentication required)
|
||||||
router.get("/remove", async (_req, res) => {
|
router.get("/remove", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ router.get("/", async (req, res) => {
|
|||||||
latest_version: true,
|
latest_version: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
_count: {
|
_count: {
|
||||||
host_packages: true,
|
select: {
|
||||||
|
host_packages: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
skip,
|
skip,
|
||||||
@@ -82,7 +84,7 @@ router.get("/", async (req, res) => {
|
|||||||
// Get additional stats for each package
|
// Get additional stats for each package
|
||||||
const packagesWithStats = await Promise.all(
|
const packagesWithStats = await Promise.all(
|
||||||
packages.map(async (pkg) => {
|
packages.map(async (pkg) => {
|
||||||
const [updatesCount, securityCount, affectedHosts] = await Promise.all([
|
const [updatesCount, securityCount, packageHosts] = await Promise.all([
|
||||||
prisma.host_packages.count({
|
prisma.host_packages.count({
|
||||||
where: {
|
where: {
|
||||||
package_id: pkg.id,
|
package_id: pkg.id,
|
||||||
@@ -117,17 +119,18 @@ router.get("/", async (req, res) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...pkg,
|
...pkg,
|
||||||
affectedHostsCount: pkg._count.hostPackages,
|
packageHostsCount: pkg._count.host_packages,
|
||||||
affectedHosts: affectedHosts.map((hp) => ({
|
packageHosts: packageHosts.map((hp) => ({
|
||||||
hostId: hp.host.id,
|
hostId: hp.hosts.id,
|
||||||
friendlyName: hp.host.friendly_name,
|
friendlyName: hp.hosts.friendly_name,
|
||||||
osType: hp.host.os_type,
|
osType: hp.hosts.os_type,
|
||||||
currentVersion: hp.current_version,
|
currentVersion: hp.current_version,
|
||||||
availableVersion: hp.available_version,
|
availableVersion: hp.available_version,
|
||||||
|
needsUpdate: hp.needs_update,
|
||||||
isSecurityUpdate: hp.is_security_update,
|
isSecurityUpdate: hp.is_security_update,
|
||||||
})),
|
})),
|
||||||
stats: {
|
stats: {
|
||||||
totalInstalls: pkg._count.hostPackages,
|
totalInstalls: pkg._count.host_packages,
|
||||||
updatesNeeded: updatesCount,
|
updatesNeeded: updatesCount,
|
||||||
securityUpdates: securityCount,
|
securityUpdates: securityCount,
|
||||||
},
|
},
|
||||||
@@ -160,19 +163,19 @@ router.get("/:packageId", async (req, res) => {
|
|||||||
include: {
|
include: {
|
||||||
host_packages: {
|
host_packages: {
|
||||||
include: {
|
include: {
|
||||||
host: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
ip: true,
|
ip: true,
|
||||||
osType: true,
|
os_type: true,
|
||||||
osVersion: true,
|
os_version: true,
|
||||||
lastUpdate: true,
|
last_update: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
needsUpdate: "desc",
|
needs_update: "desc",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -185,25 +188,25 @@ router.get("/:packageId", async (req, res) => {
|
|||||||
// Calculate statistics
|
// Calculate statistics
|
||||||
const stats = {
|
const stats = {
|
||||||
totalInstalls: packageData.host_packages.length,
|
totalInstalls: packageData.host_packages.length,
|
||||||
updatesNeeded: packageData.host_packages.filter((hp) => hp.needsUpdate)
|
updatesNeeded: packageData.host_packages.filter((hp) => hp.needs_update)
|
||||||
.length,
|
.length,
|
||||||
securityUpdates: packageData.host_packages.filter(
|
securityUpdates: packageData.host_packages.filter(
|
||||||
(hp) => hp.needsUpdate && hp.isSecurityUpdate,
|
(hp) => hp.needs_update && hp.is_security_update,
|
||||||
).length,
|
).length,
|
||||||
upToDate: packageData.host_packages.filter((hp) => !hp.needsUpdate)
|
upToDate: packageData.host_packages.filter((hp) => !hp.needs_update)
|
||||||
.length,
|
.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group by version
|
// Group by version
|
||||||
const versionDistribution = packageData.host_packages.reduce((acc, hp) => {
|
const versionDistribution = packageData.host_packages.reduce((acc, hp) => {
|
||||||
const version = hp.currentVersion;
|
const version = hp.current_version;
|
||||||
acc[version] = (acc[version] || 0) + 1;
|
acc[version] = (acc[version] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Group by OS type
|
// Group by OS type
|
||||||
const osDistribution = packageData.host_packages.reduce((acc, hp) => {
|
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;
|
acc[osType] = (acc[osType] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -230,4 +233,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;
|
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)
|
// Cleanup orphaned repositories (admin only)
|
||||||
router.delete(
|
router.delete(
|
||||||
"/cleanup/orphaned",
|
"/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;
|
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) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +248,9 @@ router.put(
|
|||||||
githubRepoUrl,
|
githubRepoUrl,
|
||||||
repositoryType,
|
repositoryType,
|
||||||
sshKeyPath,
|
sshKeyPath,
|
||||||
|
logoDark,
|
||||||
|
logoLight,
|
||||||
|
favicon,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Get current settings to check for update interval changes
|
// Get current settings to check for update interval changes
|
||||||
@@ -264,6 +279,9 @@ router.put(
|
|||||||
if (repositoryType !== undefined)
|
if (repositoryType !== undefined)
|
||||||
updateData.repository_type = repositoryType;
|
updateData.repository_type = repositoryType;
|
||||||
if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath;
|
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(
|
const updatedSettings = await updateSettings(
|
||||||
currentSettings.id,
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,36 +2,211 @@ const express = require("express");
|
|||||||
const { authenticateToken } = require("../middleware/auth");
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
const { requireManageSettings } = require("../middleware/permissions");
|
const { requireManageSettings } = require("../middleware/permissions");
|
||||||
const { PrismaClient } = require("@prisma/client");
|
const { PrismaClient } = require("@prisma/client");
|
||||||
const { exec } = require("node:child_process");
|
|
||||||
const { promisify } = require("node:util");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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();
|
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.7";
|
||||||
|
} catch (packageError) {
|
||||||
|
console.warn(
|
||||||
|
"Could not read version from package.json, using fallback:",
|
||||||
|
packageError.message,
|
||||||
|
);
|
||||||
|
return "1.2.7";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
const currentVersionTag = `v${currentVersion}`;
|
||||||
|
// Compare main branch with the released version tag
|
||||||
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${currentVersionTag}...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");
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
console.error("Error fetching commit difference:", error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Get current version info
|
||||||
router.get("/current", authenticateToken, async (_req, res) => {
|
router.get("/current", authenticateToken, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
// Read version from package.json dynamically
|
const currentVersion = getCurrentVersion();
|
||||||
let currentVersion = "1.2.7"; // fallback
|
|
||||||
|
|
||||||
try {
|
// Get settings with cached update info (no GitHub API calls)
|
||||||
const packageJson = require("../../package.json");
|
const settings = await prisma.settings.findFirst();
|
||||||
if (packageJson?.version) {
|
const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||||
currentVersion = packageJson.version;
|
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
|
||||||
}
|
|
||||||
} catch (packageError) {
|
|
||||||
console.warn(
|
|
||||||
"Could not read version from package.json, using fallback:",
|
|
||||||
packageError.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Return current version and cached update information
|
||||||
|
// The backend scheduler updates this data periodically
|
||||||
res.json({
|
res.json({
|
||||||
version: currentVersion,
|
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(),
|
buildDate: new Date().toISOString(),
|
||||||
environment: process.env.NODE_ENV || "development",
|
environment: process.env.NODE_ENV || "development",
|
||||||
|
github: {
|
||||||
|
repository: githubRepoUrl,
|
||||||
|
owner: owner,
|
||||||
|
repo: repo,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error getting current version:", error);
|
console.error("Error getting current version:", error);
|
||||||
@@ -44,119 +219,11 @@ router.post(
|
|||||||
"/test-ssh-key",
|
"/test-ssh-key",
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
requireManageSettings,
|
requireManageSettings,
|
||||||
async (req, res) => {
|
async (_req, res) => {
|
||||||
try {
|
res.status(410).json({
|
||||||
const { sshKeyPath, githubRepoUrl } = req.body;
|
error:
|
||||||
|
"SSH key testing has been removed. Using default public repository.",
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -174,24 +241,90 @@ router.get(
|
|||||||
return res.status(400).json({ error: "Settings not found" });
|
return res.status(400).json({ error: "Settings not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = "1.2.7";
|
const currentVersion = getCurrentVersion();
|
||||||
const latestVersion = settings.latest_version || currentVersion;
|
const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||||
const isUpdateAvailable = settings.update_available || false;
|
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
|
||||||
const lastUpdateCheck = settings.last_update_check || null;
|
|
||||||
|
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: "v1.2.7",
|
||||||
|
version: "1.2.7",
|
||||||
|
publishedAt: "2025-10-02T17:12:53Z",
|
||||||
|
htmlUrl:
|
||||||
|
"https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7",
|
||||||
|
};
|
||||||
|
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
|
||||||
|
latestRelease = settings.latest_version
|
||||||
|
? {
|
||||||
|
version: settings.latest_version,
|
||||||
|
tagName: `v${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({
|
res.json({
|
||||||
currentVersion,
|
currentVersion,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
isUpdateAvailable,
|
isUpdateAvailable,
|
||||||
lastUpdateCheck,
|
lastUpdateCheck: settings.last_update_check || null,
|
||||||
repositoryType: settings.repository_type || "public",
|
repositoryType: settings.repository_type || "public",
|
||||||
latestRelease: {
|
github: {
|
||||||
tagName: latestVersion ? `v${latestVersion}` : null,
|
repository: githubRepoUrl,
|
||||||
version: latestVersion,
|
owner: owner,
|
||||||
repository: settings.github_repo_url
|
repo: repo,
|
||||||
? settings.github_repo_url.split("/").slice(-2).join("/")
|
latestRelease: latestRelease,
|
||||||
: null,
|
latestCommit: latestCommit,
|
||||||
accessMethod: settings.repository_type === "private" ? "ssh" : "api",
|
commitDifference: commitDifference,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const {
|
|||||||
const repositoryRoutes = require("./routes/repositoryRoutes");
|
const repositoryRoutes = require("./routes/repositoryRoutes");
|
||||||
const versionRoutes = require("./routes/versionRoutes");
|
const versionRoutes = require("./routes/versionRoutes");
|
||||||
const tfaRoutes = require("./routes/tfaRoutes");
|
const tfaRoutes = require("./routes/tfaRoutes");
|
||||||
|
const searchRoutes = require("./routes/searchRoutes");
|
||||||
|
const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
|
||||||
const updateScheduler = require("./services/updateScheduler");
|
const updateScheduler = require("./services/updateScheduler");
|
||||||
const { initSettings } = require("./services/settingsService");
|
const { initSettings } = require("./services/settingsService");
|
||||||
const { cleanup_expired_sessions } = require("./utils/session_manager");
|
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}/repositories`, repositoryRoutes);
|
||||||
app.use(`/api/${apiVersion}/version`, versionRoutes);
|
app.use(`/api/${apiVersion}/version`, versionRoutes);
|
||||||
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
|
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
|
||||||
|
app.use(`/api/${apiVersion}/search`, searchRoutes);
|
||||||
|
app.use(
|
||||||
|
`/api/${apiVersion}/auto-enrollment`,
|
||||||
|
authLimiter,
|
||||||
|
autoEnrollmentRoutes,
|
||||||
|
);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, _req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
|
|||||||
@@ -60,13 +60,8 @@ class UpdateScheduler {
|
|||||||
|
|
||||||
// Get settings
|
// Get settings
|
||||||
const settings = await prisma.settings.findFirst();
|
const settings = await prisma.settings.findFirst();
|
||||||
if (!settings || !settings.githubRepoUrl) {
|
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
|
||||||
console.log("⚠️ No GitHub repository configured, skipping update check");
|
const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract owner and repo from GitHub URL
|
|
||||||
const repoUrl = settings.githubRepoUrl;
|
|
||||||
let owner, repo;
|
let owner, repo;
|
||||||
|
|
||||||
if (repoUrl.includes("git@github.com:")) {
|
if (repoUrl.includes("git@github.com:")) {
|
||||||
@@ -128,9 +123,9 @@ class UpdateScheduler {
|
|||||||
await prisma.settings.update({
|
await prisma.settings.update({
|
||||||
where: { id: settings.id },
|
where: { id: settings.id },
|
||||||
data: {
|
data: {
|
||||||
lastUpdateCheck: new Date(),
|
last_update_check: new Date(),
|
||||||
updateAvailable: isUpdateAvailable,
|
update_available: isUpdateAvailable,
|
||||||
latestVersion: latestVersion,
|
latest_version: latestVersion,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,8 +142,8 @@ class UpdateScheduler {
|
|||||||
await prisma.settings.update({
|
await prisma.settings.update({
|
||||||
where: { id: settings.id },
|
where: { id: settings.id },
|
||||||
data: {
|
data: {
|
||||||
lastUpdateCheck: new Date(),
|
last_update_check: new Date(),
|
||||||
updateAvailable: false,
|
update_available: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -241,6 +236,16 @@ class UpdateScheduler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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(
|
throw new Error(
|
||||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const crypto = require("crypto");
|
const crypto = require("node:crypto");
|
||||||
const { PrismaClient } = require("@prisma/client");
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -9,7 +9,10 @@ const prisma = new PrismaClient();
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Configuration
|
// 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_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
|
||||||
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
|
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
|
||||||
const INACTIVITY_TIMEOUT_MINUTES = parseInt(
|
const INACTIVITY_TIMEOUT_MINUTES = parseInt(
|
||||||
|
|||||||
@@ -6,14 +6,21 @@ PatchMon is a containerised application that monitors system patches and updates
|
|||||||
|
|
||||||
- **Database**: PostgreSQL 17
|
- **Database**: PostgreSQL 17
|
||||||
- **Backend**: Node.js API server
|
- **Backend**: Node.js API server
|
||||||
- **Frontend**: React application served via Nginx
|
- **Frontend**: React application served via NGINX
|
||||||
|
|
||||||
## Images
|
## Images
|
||||||
|
|
||||||
- **Backend**: [ghcr.io/patchmon/patchmon-backend:latest](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-backend)
|
- **Backend**: [ghcr.io/patchmon/patchmon-backend](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)
|
- **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.
|
||||||
|
|
||||||
|
These tags are available for both backend and frontend images as they are versioned together.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
|
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
|||||||
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,6 +1,7 @@
|
|||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes } from "react-router-dom";
|
||||||
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
|
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
|
import LogoProvider from "./components/LogoProvider";
|
||||||
import ProtectedRoute from "./components/ProtectedRoute";
|
import ProtectedRoute from "./components/ProtectedRoute";
|
||||||
import SettingsLayout from "./components/SettingsLayout";
|
import SettingsLayout from "./components/SettingsLayout";
|
||||||
import { isAuthPhase } from "./constants/authPhases";
|
import { isAuthPhase } from "./constants/authPhases";
|
||||||
@@ -290,6 +291,16 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/branding"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsServerConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/settings/agent-version"
|
path="/settings/agent-version"
|
||||||
element={
|
element={
|
||||||
@@ -329,7 +340,9 @@ function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<UpdateNotificationProvider>
|
<UpdateNotificationProvider>
|
||||||
<AppRoutes />
|
<LogoProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</LogoProvider>
|
||||||
</UpdateNotificationProvider>
|
</UpdateNotificationProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</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;
|
||||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -13,13 +14,12 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
Menu,
|
Menu,
|
||||||
MessageCircle,
|
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Route,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
|
||||||
Star,
|
Star,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
X,
|
X,
|
||||||
@@ -29,6 +29,9 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||||
import { dashboardAPI, versionAPI } from "../utils/api";
|
import { dashboardAPI, versionAPI } from "../utils/api";
|
||||||
|
import DiscordIcon from "./DiscordIcon";
|
||||||
|
import GlobalSearch from "./GlobalSearch";
|
||||||
|
import Logo from "./Logo";
|
||||||
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
|
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
|
||||||
|
|
||||||
const Layout = ({ children }) => {
|
const Layout = ({ children }) => {
|
||||||
@@ -292,7 +295,7 @@ const Layout = ({ children }) => {
|
|||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
aria-label="Close sidebar"
|
aria-label="Close sidebar"
|
||||||
/>
|
/>
|
||||||
<div className="relative flex w-full max-w-xs flex-col bg-white pb-4 pt-5 shadow-xl">
|
<div className="relative flex w-full max-w-[280px] flex-col bg-white pb-4 pt-5 shadow-xl">
|
||||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -302,13 +305,10 @@ const Layout = ({ children }) => {
|
|||||||
<X className="h-6 w-6 text-white" />
|
<X className="h-6 w-6 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-shrink-0 items-center px-4">
|
<div className="flex flex-shrink-0 items-center justify-center px-4">
|
||||||
<div className="flex items-center">
|
<Link to="/" className="flex items-center">
|
||||||
<Shield className="h-8 w-8 text-primary-600" />
|
<Logo className="h-10 w-auto" alt="PatchMon Logo" />
|
||||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
|
</Link>
|
||||||
PatchMon
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<nav className="mt-8 flex-1 space-y-6 px-2">
|
<nav className="mt-8 flex-1 space-y-6 px-2">
|
||||||
{/* Show message for users with very limited permissions */}
|
{/* Show message for users with very limited permissions */}
|
||||||
@@ -344,7 +344,7 @@ const Layout = ({ children }) => {
|
|||||||
// Section with items
|
// Section with items
|
||||||
return (
|
return (
|
||||||
<div key={item.section}>
|
<div key={item.section}>
|
||||||
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2 px-2">
|
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2">
|
||||||
{item.section}
|
{item.section}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -464,8 +464,8 @@ const Layout = ({ children }) => {
|
|||||||
|
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<div
|
<div
|
||||||
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
|
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${
|
||||||
sidebarCollapsed ? "lg:w-16" : "lg:w-64"
|
sidebarCollapsed ? "lg:w-16" : "lg:w-56"
|
||||||
} bg-white dark:bg-secondary-800`}
|
} bg-white dark:bg-secondary-800`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -474,38 +474,37 @@ const Layout = ({ children }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
|
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
|
||||||
sidebarCollapsed ? "justify-center" : "justify-between"
|
sidebarCollapsed ? "justify-center" : "justify-center"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? (
|
{sidebarCollapsed ? (
|
||||||
<button
|
<Link to="/" className="flex items-center">
|
||||||
type="button"
|
<img
|
||||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
src="/assets/favicon.svg"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
alt="PatchMon"
|
||||||
title="Expand sidebar"
|
className="h-12 w-12 object-contain"
|
||||||
>
|
/>
|
||||||
<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
|
</Link>
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Link to="/" className="flex items-center">
|
||||||
<div className="flex items-center">
|
<Logo className="h-10 w-auto" alt="PatchMon Logo" />
|
||||||
<Shield className="h-8 w-8 text-primary-600" />
|
</Link>
|
||||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
|
|
||||||
PatchMon
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
|
||||||
title="Collapse sidebar"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-5 w-5 text-secondary-700 dark:text-white" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Collapse/Expand button on border */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||||
|
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<nav className="flex flex-1 flex-col">
|
<nav className="flex flex-1 flex-col">
|
||||||
<ul className="flex flex-1 flex-col gap-y-6">
|
<ul className="flex flex-1 flex-col gap-y-6">
|
||||||
{/* Show message for users with very limited permissions */}
|
{/* Show message for users with very limited permissions */}
|
||||||
@@ -523,7 +522,10 @@ const Layout = ({ children }) => {
|
|||||||
if (item.name) {
|
if (item.name) {
|
||||||
// Single item (Dashboard)
|
// Single item (Dashboard)
|
||||||
return (
|
return (
|
||||||
<li key={item.name}>
|
<li
|
||||||
|
key={item.name}
|
||||||
|
className={sidebarCollapsed ? "" : "-mx-2"}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
|
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
|
||||||
@@ -547,7 +549,7 @@ const Layout = ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<li key={item.section}>
|
<li key={item.section}>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2 px-2">
|
<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2">
|
||||||
{item.section}
|
{item.section}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
@@ -849,7 +851,7 @@ const Layout = ({ children }) => {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col min-h-screen transition-all duration-300 ${
|
className={`flex flex-col min-h-screen transition-all duration-300 ${
|
||||||
sidebarCollapsed ? "lg:pl-16" : "lg:pl-64"
|
sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
@@ -866,16 +868,22 @@ const Layout = ({ children }) => {
|
|||||||
<div className="h-6 w-px bg-secondary-200 lg:hidden" />
|
<div className="h-6 w-px bg-secondary-200 lg:hidden" />
|
||||||
|
|
||||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||||
<div className="relative flex flex-1 items-center">
|
<div className="relative flex items-center">
|
||||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 whitespace-nowrap">
|
||||||
{getPageTitle()}
|
{getPageTitle()}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
|
||||||
|
{/* Global Search Bar */}
|
||||||
|
<div className="hidden md:flex items-center max-w-sm">
|
||||||
|
<GlobalSearch />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center gap-x-4 lg:gap-x-6 justify-end">
|
||||||
{/* External Links */}
|
{/* External Links */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/9technologygroup/patchmon.net"
|
href="https://github.com/PatchMon/PatchMon"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm group relative"
|
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm group relative"
|
||||||
@@ -888,6 +896,15 @@ const Layout = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/orgs/PatchMon/projects/2/views/1"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||||
|
title="Roadmap"
|
||||||
|
>
|
||||||
|
<Route className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://patchmon.net/discord"
|
href="https://patchmon.net/discord"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -895,7 +912,16 @@ const Layout = ({ children }) => {
|
|||||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||||
title="Discord"
|
title="Discord"
|
||||||
>
|
>
|
||||||
<MessageCircle className="h-5 w-5" />
|
<DiscordIcon className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://docs.patchmon.net"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||||
|
title="Documentation"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="mailto:support@patchmon.net"
|
href="mailto:support@patchmon.net"
|
||||||
|
|||||||
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;
|
||||||
37
frontend/src/components/LogoProvider.jsx
Normal file
37
frontend/src/components/LogoProvider.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { settingsAPI } from "../utils/api";
|
||||||
|
|
||||||
|
const LogoProvider = ({ children }) => {
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
ChevronRight,
|
||||||
Code,
|
Code,
|
||||||
Folder,
|
Folder,
|
||||||
|
Image,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -130,6 +131,11 @@ const SettingsLayout = ({ children }) => {
|
|||||||
href: "/settings/server-url",
|
href: "/settings/server-url",
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Branding",
|
||||||
|
href: "/settings/branding",
|
||||||
|
icon: Image,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Server Version",
|
name: "Server Version",
|
||||||
href: "/settings/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;
|
||||||
@@ -92,7 +92,12 @@ const UsersTab = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditUser = (user) => {
|
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) => {
|
const handleResetPassword = (user) => {
|
||||||
@@ -314,7 +319,9 @@ const UsersTab = () => {
|
|||||||
user={editingUser}
|
user={editingUser}
|
||||||
isOpen={!!editingUser}
|
isOpen={!!editingUser}
|
||||||
onClose={() => setEditingUser(null)}
|
onClose={() => setEditingUser(null)}
|
||||||
onUserUpdated={() => updateUserMutation.mutate()}
|
onUserUpdated={() => {
|
||||||
|
queryClient.invalidateQueries(["users"]);
|
||||||
|
}}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -352,11 +359,29 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
|||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Only send role if roles are available from API
|
// Only send role if roles are available from API
|
||||||
@@ -364,12 +389,19 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
|||||||
username: formData.username,
|
username: formData.username,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
|
first_name: formData.first_name,
|
||||||
|
last_name: formData.last_name,
|
||||||
};
|
};
|
||||||
if (roles && Array.isArray(roles) && roles.length > 0) {
|
if (roles && Array.isArray(roles) && roles.length > 0) {
|
||||||
payload.role = formData.role;
|
payload.role = formData.role;
|
||||||
}
|
}
|
||||||
await adminUsersAPI.create(payload);
|
await adminUsersAPI.create(payload);
|
||||||
|
setSuccess(true);
|
||||||
onUserCreated();
|
onUserCreated();
|
||||||
|
// Auto-close after 1.5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || "Failed to create user");
|
setError(err.response?.data?.error || "Failed to create user");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -517,6 +549,17 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
<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">
|
<p className="text-sm text-danger-700 dark:text-danger-300">
|
||||||
@@ -566,15 +609,44 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
|||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await adminUsersAPI.update(user.id, formData);
|
await adminUsersAPI.update(user.id, formData);
|
||||||
|
setSuccess(true);
|
||||||
onUserUpdated();
|
onUserUpdated();
|
||||||
|
// Auto-close after 1.5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || "Failed to update user");
|
setError(err.response?.data?.error || "Failed to update user");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -718,6 +790,17 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
<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">
|
<p className="text-sm text-danger-700 dark:text-danger-300">
|
||||||
|
|||||||
@@ -1,30 +1,16 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Code,
|
Code,
|
||||||
Download,
|
Download,
|
||||||
Save,
|
ExternalLink,
|
||||||
|
GitCommit,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useId, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { settingsAPI, versionAPI } from "../../utils/api";
|
import { versionAPI } from "../../utils/api";
|
||||||
|
|
||||||
const VersionUpdateTab = () => {
|
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
|
// Version checking state
|
||||||
const [versionInfo, setVersionInfo] = useState({
|
const [versionInfo, setVersionInfo] = useState({
|
||||||
currentVersion: null,
|
currentVersion: null,
|
||||||
@@ -32,89 +18,11 @@ const VersionUpdateTab = () => {
|
|||||||
isUpdateAvailable: false,
|
isUpdateAvailable: false,
|
||||||
checking: false,
|
checking: false,
|
||||||
error: null,
|
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
|
// Version checking functions
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = useCallback(async () => {
|
||||||
setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
|
setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -126,6 +34,7 @@ const VersionUpdateTab = () => {
|
|||||||
latestVersion: data.latestVersion,
|
latestVersion: data.latestVersion,
|
||||||
isUpdateAvailable: data.isUpdateAvailable,
|
isUpdateAvailable: data.isUpdateAvailable,
|
||||||
last_update_check: data.last_update_check,
|
last_update_check: data.last_update_check,
|
||||||
|
github: data.github,
|
||||||
checking: false,
|
checking: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -137,434 +46,274 @@ const VersionUpdateTab = () => {
|
|||||||
error: error.response?.data?.error || "Failed to check for updates",
|
error: error.response?.data?.error || "Failed to check for updates",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const testSshKey = async () => {
|
// Load current version and automatically check for updates on component mount
|
||||||
if (!formData.sshKeyPath || !formData.githubRepoUrl) {
|
useEffect(() => {
|
||||||
setSshTestResult({
|
const loadAndCheckUpdates = async () => {
|
||||||
testing: false,
|
try {
|
||||||
success: false,
|
// First, get current version info
|
||||||
message: null,
|
const response = await versionAPI.getCurrent();
|
||||||
error: "Please enter both SSH key path and GitHub repository URL",
|
const data = response.data;
|
||||||
});
|
setVersionInfo({
|
||||||
return;
|
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({
|
// Then automatically trigger a fresh update check
|
||||||
testing: true,
|
await checkForUpdates();
|
||||||
success: null,
|
} catch (error) {
|
||||||
message: null,
|
console.error("Error loading version info:", error);
|
||||||
error: null,
|
setVersionInfo((prev) => ({
|
||||||
});
|
...prev,
|
||||||
|
error: "Failed to load version information",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
loadAndCheckUpdates();
|
||||||
const response = await versionAPI.testSshKey({
|
}, [checkForUpdates]); // Run when component mounts
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="flex items-center mb-6">
|
||||||
<Code className="h-6 w-6 text-primary-600 mr-3" />
|
<Code className="h-6 w-6 text-primary-600 mr-3" />
|
||||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
Server Version Management
|
Server Version Information
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
|
<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">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
Version Check Configuration
|
Version Information
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
|
||||||
Configure automatic version checking against your GitHub repository to
|
Current server version and latest updates from GitHub repository.
|
||||||
notify users of available updates.
|
{versionInfo.checking && (
|
||||||
|
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||||||
|
🔄 Checking for updates...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<fieldset>
|
{/* My Version */}
|
||||||
<legend className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||||
Repository Type
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</legend>
|
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
<div className="space-y-2">
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||||
<div className="flex items-center">
|
My Version
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!formData.useCustomSshKey && (
|
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Using auto-detection for SSH key location
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||||
|
{versionInfo.currentVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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="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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
<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">
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||||
Latest Version
|
Latest Release
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
<div className="space-y-1">
|
||||||
{versionInfo.checking ? (
|
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||||
<span className="text-blue-600 dark:text-blue-400">
|
{versionInfo.github.latestRelease.tagName}
|
||||||
Checking...
|
|
||||||
</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>
|
</span>
|
||||||
</div>
|
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
Published:{" "}
|
||||||
{new Date(versionInfo.last_update_check).toLocaleString()}
|
{new Date(
|
||||||
</span>
|
versionInfo.github.latestRelease.publishedAt,
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
).toLocaleDateString()}
|
||||||
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}
|
|
||||||
</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>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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 { isAuthReady } from "../constants/authPhases";
|
||||||
import { settingsAPI, versionAPI } from "../utils/api";
|
import { settingsAPI } from "../utils/api";
|
||||||
import { useAuth } from "./AuthContext";
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
const UpdateNotificationContext = createContext();
|
const UpdateNotificationContext = createContext();
|
||||||
@@ -21,6 +21,7 @@ export const UpdateNotificationProvider = ({ children }) => {
|
|||||||
const { authPhase, isAuthenticated } = useAuth();
|
const { authPhase, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
// Ensure settings are loaded - but only after auth is fully ready
|
// 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({
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||||
queryKey: ["settings"],
|
queryKey: ["settings"],
|
||||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
@@ -29,31 +30,20 @@ export const UpdateNotificationProvider = ({ children }) => {
|
|||||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize the enabled condition to prevent unnecessary re-evaluations
|
// Read cached update information from settings (no GitHub API calls)
|
||||||
const isQueryEnabled = useMemo(() => {
|
// The backend scheduler updates this data periodically
|
||||||
return (
|
const updateAvailable = settings?.is_update_available && !dismissed;
|
||||||
isAuthReady(authPhase, isAuthenticated()) &&
|
const updateInfo = settings
|
||||||
!!settings &&
|
? {
|
||||||
!settingsLoading
|
isUpdateAvailable: settings.is_update_available,
|
||||||
);
|
latestVersion: settings.latest_version,
|
||||||
}, [authPhase, isAuthenticated, settings, settingsLoading]);
|
currentVersion: settings.current_version,
|
||||||
|
last_update_check: settings.last_update_check,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
// Query for update information
|
const isLoading = settingsLoading;
|
||||||
const {
|
const error = null;
|
||||||
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 dismissNotification = () => {
|
const dismissNotification = () => {
|
||||||
setDismissed(true);
|
setDismissed(true);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const HostDetail = () => {
|
|||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showAllUpdates, setShowAllUpdates] = useState(false);
|
const [showAllUpdates, setShowAllUpdates] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("host");
|
const [activeTab, setActiveTab] = useState("host");
|
||||||
|
const [_forceInstall, _setForceInstall] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: host,
|
data: host,
|
||||||
@@ -387,6 +388,17 @@ const HostDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{host.machine_id && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
|
||||||
|
Machine ID
|
||||||
|
</p>
|
||||||
|
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
|
||||||
|
{host.machine_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
|
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
|
||||||
Host Group
|
Host Group
|
||||||
@@ -455,11 +467,23 @@ const HostDetail = () => {
|
|||||||
|
|
||||||
{/* Network Information */}
|
{/* Network Information */}
|
||||||
{activeTab === "network" &&
|
{activeTab === "network" &&
|
||||||
(host.gateway_ip ||
|
(host.ip ||
|
||||||
|
host.gateway_ip ||
|
||||||
host.dns_servers ||
|
host.dns_servers ||
|
||||||
host.network_interfaces) && (
|
host.network_interfaces) && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{host.ip && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||||
|
IP Address
|
||||||
|
</p>
|
||||||
|
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
|
||||||
|
{host.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{host.gateway_ip && (
|
{host.gateway_ip && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||||
@@ -791,6 +815,7 @@ const HostDetail = () => {
|
|||||||
|
|
||||||
{activeTab === "network" &&
|
{activeTab === "network" &&
|
||||||
!(
|
!(
|
||||||
|
host.ip ||
|
||||||
host.gateway_ip ||
|
host.gateway_ip ||
|
||||||
host.dns_servers ||
|
host.dns_servers ||
|
||||||
host.network_interfaces
|
host.network_interfaces
|
||||||
@@ -1059,6 +1084,7 @@ const HostDetail = () => {
|
|||||||
const CredentialsModal = ({ host, isOpen, onClose }) => {
|
const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("quick-install");
|
const [activeTab, setActiveTab] = useState("quick-install");
|
||||||
|
const [forceInstall, setForceInstall] = useState(false);
|
||||||
const apiIdInputId = useId();
|
const apiIdInputId = useId();
|
||||||
const apiKeyInputId = useId();
|
const apiKeyInputId = useId();
|
||||||
|
|
||||||
@@ -1080,6 +1106,12 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
|||||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to build installation URL with optional force flag
|
||||||
|
const getInstallUrl = () => {
|
||||||
|
const baseUrl = `${serverUrl}/api/v1/hosts/install`;
|
||||||
|
return forceInstall ? `${baseUrl}?force=true` : baseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
const copyToClipboard = async (text) => {
|
const copyToClipboard = async (text) => {
|
||||||
try {
|
try {
|
||||||
// Try modern clipboard API first
|
// Try modern clipboard API first
|
||||||
@@ -1173,10 +1205,30 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
|||||||
Copy and run this command on the target host to securely install
|
Copy and run this command on the target host to securely install
|
||||||
and configure the PatchMon agent:
|
and configure the PatchMon agent:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Force Install Toggle */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceInstall}
|
||||||
|
onChange={(e) => setForceInstall(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-primary-800 dark:text-primary-200">
|
||||||
|
Force install (bypass broken packages)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
|
||||||
|
Enable this if the target host has broken packages
|
||||||
|
(CloudPanel, WHM, etc.) that block apt-get operations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={`curl ${getCurlFlags()} ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
|
value={`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
|
||||||
readOnly
|
readOnly
|
||||||
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||||
/>
|
/>
|
||||||
@@ -1184,7 +1236,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyToClipboard(
|
copyToClipboard(
|
||||||
`curl ${getCurlFlags()} ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`,
|
`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="btn-primary flex items-center gap-1"
|
className="btn-primary flex items-center gap-1"
|
||||||
|
|||||||
@@ -1,23 +1,476 @@
|
|||||||
import { Package } from "lucide-react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useParams } from "react-router-dom";
|
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 PackageDetail = () => {
|
||||||
const { packageId } = useParams();
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="card p-8 text-center">
|
{/* Header */}
|
||||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 mb-2">
|
<div className="flex items-center gap-4">
|
||||||
Package Details
|
<button
|
||||||
</h3>
|
type="button"
|
||||||
<p className="text-secondary-600">
|
onClick={() => navigate("/packages")}
|
||||||
Detailed view for package: {packageId}
|
className="flex items-center gap-2 text-secondary-600 hover:text-secondary-900 dark:text-secondary-400 dark:hover:text-white transition-colors"
|
||||||
</p>
|
>
|
||||||
<p className="text-secondary-600 mt-2">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
This page will show package information, affected hosts, version
|
Back to Packages
|
||||||
distribution, and more.
|
</button>
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
Columns,
|
Columns,
|
||||||
Eye as EyeIcon,
|
Eye as EyeIcon,
|
||||||
EyeOff as EyeOffIcon,
|
EyeOff as EyeOffIcon,
|
||||||
@@ -17,16 +19,28 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { dashboardAPI } from "../utils/api";
|
import { dashboardAPI, packagesAPI } from "../utils/api";
|
||||||
|
|
||||||
const Packages = () => {
|
const Packages = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
const [securityFilter, setSecurityFilter] = useState("all");
|
const [updateStatusFilter, setUpdateStatusFilter] = useState("all-packages");
|
||||||
const [hostFilter, setHostFilter] = useState("all");
|
const [hostFilter, setHostFilter] = useState("all");
|
||||||
const [sortField, setSortField] = useState("name");
|
const [sortField, setSortField] = useState("name");
|
||||||
const [sortDirection, setSortDirection] = useState("asc");
|
const [sortDirection, setSortDirection] = useState("asc");
|
||||||
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
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 [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -42,8 +56,8 @@ const Packages = () => {
|
|||||||
const [columnConfig, setColumnConfig] = useState(() => {
|
const [columnConfig, setColumnConfig] = useState(() => {
|
||||||
const defaultConfig = [
|
const defaultConfig = [
|
||||||
{ id: "name", label: "Package", visible: true, order: 0 },
|
{ id: "name", label: "Package", visible: true, order: 0 },
|
||||||
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
|
{ id: "packageHosts", label: "Installed On", visible: true, order: 1 },
|
||||||
{ id: "priority", label: "Priority", visible: true, order: 2 },
|
{ id: "status", label: "Status", visible: true, order: 2 },
|
||||||
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
|
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -65,10 +79,10 @@ const Packages = () => {
|
|||||||
localStorage.setItem("packages-column-config", JSON.stringify(newConfig));
|
localStorage.setItem("packages-column-config", JSON.stringify(newConfig));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle affected hosts click
|
// Handle hosts click (view hosts where package is installed)
|
||||||
const handleAffectedHostsClick = (pkg) => {
|
const handlePackageHostsClick = (pkg) => {
|
||||||
const affectedHosts = pkg.affectedHosts || [];
|
const packageHosts = pkg.packageHosts || [];
|
||||||
const hostIds = affectedHosts.map((host) => host.hostId);
|
const hostIds = packageHosts.map((host) => host.hostId);
|
||||||
|
|
||||||
// Create URL with selected hosts and filter
|
// Create URL with selected hosts and filter
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -86,27 +100,43 @@ const Packages = () => {
|
|||||||
// For outdated packages, we want to show all packages that need updates
|
// 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
|
// This is the default behavior, so we don't need to change filters
|
||||||
setCategoryFilter("all");
|
setCategoryFilter("all");
|
||||||
setSecurityFilter("all");
|
setUpdateStatusFilter("needs-updates");
|
||||||
} else if (filter === "security") {
|
} else if (filter === "security") {
|
||||||
// For security updates, filter to show only security updates
|
// For security updates, filter to show only security updates
|
||||||
setSecurityFilter("security");
|
setUpdateStatusFilter("security-updates");
|
||||||
setCategoryFilter("all");
|
setCategoryFilter("all");
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: packages,
|
data: packagesResponse,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
isFetching,
|
isFetching,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["packages"],
|
queryKey: ["packages"],
|
||||||
queryFn: () => dashboardAPI.getPackages().then((res) => res.data),
|
queryFn: () => packagesAPI.getAll({ limit: 1000 }).then((res) => res.data),
|
||||||
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
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
|
// Fetch hosts data to get total packages count
|
||||||
const { data: hosts } = useQuery({
|
const { data: hosts } = useQuery({
|
||||||
queryKey: ["hosts"],
|
queryKey: ["hosts"],
|
||||||
@@ -128,17 +158,30 @@ const Packages = () => {
|
|||||||
const matchesCategory =
|
const matchesCategory =
|
||||||
categoryFilter === "all" || pkg.category === categoryFilter;
|
categoryFilter === "all" || pkg.category === categoryFilter;
|
||||||
|
|
||||||
const matchesSecurity =
|
const matchesUpdateStatus =
|
||||||
securityFilter === "all" ||
|
updateStatusFilter === "all-packages" ||
|
||||||
(securityFilter === "security" && pkg.isSecurityUpdate) ||
|
updateStatusFilter === "needs-updates" ||
|
||||||
(securityFilter === "regular" && !pkg.isSecurityUpdate);
|
(updateStatusFilter === "security-updates" && pkg.isSecurityUpdate) ||
|
||||||
|
(updateStatusFilter === "regular-updates" && !pkg.isSecurityUpdate);
|
||||||
|
|
||||||
const affectedHosts = pkg.affectedHosts || [];
|
// For "all-packages", we don't filter by update status
|
||||||
|
// For other filters, we only show packages that need updates
|
||||||
|
const matchesUpdateNeeded =
|
||||||
|
updateStatusFilter === "all-packages" ||
|
||||||
|
(pkg.stats?.updatesNeeded || 0) > 0;
|
||||||
|
|
||||||
|
const packageHosts = pkg.packageHosts || [];
|
||||||
const matchesHost =
|
const matchesHost =
|
||||||
hostFilter === "all" ||
|
hostFilter === "all" ||
|
||||||
affectedHosts.some((host) => host.hostId === hostFilter);
|
packageHosts.some((host) => host.hostId === hostFilter);
|
||||||
|
|
||||||
return matchesSearch && matchesCategory && matchesSecurity && matchesHost;
|
return (
|
||||||
|
matchesSearch &&
|
||||||
|
matchesCategory &&
|
||||||
|
matchesUpdateStatus &&
|
||||||
|
matchesUpdateNeeded &&
|
||||||
|
matchesHost
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
@@ -154,14 +197,38 @@ const Packages = () => {
|
|||||||
aValue = a.latestVersion?.toLowerCase() || "";
|
aValue = a.latestVersion?.toLowerCase() || "";
|
||||||
bValue = b.latestVersion?.toLowerCase() || "";
|
bValue = b.latestVersion?.toLowerCase() || "";
|
||||||
break;
|
break;
|
||||||
case "affectedHosts":
|
case "packageHosts":
|
||||||
aValue = a.affectedHostsCount || a.affectedHosts?.length || 0;
|
aValue = a.packageHostsCount || a.packageHosts?.length || 0;
|
||||||
bValue = b.affectedHostsCount || b.affectedHosts?.length || 0;
|
bValue = b.packageHostsCount || b.packageHosts?.length || 0;
|
||||||
break;
|
break;
|
||||||
case "priority":
|
case "status": {
|
||||||
aValue = a.isSecurityUpdate ? 0 : 1; // Security updates first
|
// Handle sorting for the three status states: Up to Date, Update Available, Security Update Available
|
||||||
bValue = b.isSecurityUpdate ? 0 : 1;
|
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;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
aValue = a.name?.toLowerCase() || "";
|
aValue = a.name?.toLowerCase() || "";
|
||||||
bValue = b.name?.toLowerCase() || "";
|
bValue = b.name?.toLowerCase() || "";
|
||||||
@@ -177,12 +244,33 @@ const Packages = () => {
|
|||||||
packages,
|
packages,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
categoryFilter,
|
categoryFilter,
|
||||||
securityFilter,
|
updateStatusFilter,
|
||||||
sortField,
|
sortField,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
hostFilter,
|
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
|
// Get visible columns in order
|
||||||
const visibleColumns = columnConfig
|
const visibleColumns = columnConfig
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
@@ -231,8 +319,8 @@ const Packages = () => {
|
|||||||
const resetColumns = () => {
|
const resetColumns = () => {
|
||||||
const defaultConfig = [
|
const defaultConfig = [
|
||||||
{ id: "name", label: "Package", visible: true, order: 0 },
|
{ id: "name", label: "Package", visible: true, order: 0 },
|
||||||
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
|
{ id: "packageHosts", label: "Installed On", visible: true, order: 1 },
|
||||||
{ id: "priority", label: "Priority", visible: true, order: 2 },
|
{ id: "status", label: "Status", visible: true, order: 2 },
|
||||||
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
|
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
|
||||||
];
|
];
|
||||||
updateColumnConfig(defaultConfig);
|
updateColumnConfig(defaultConfig);
|
||||||
@@ -243,10 +331,14 @@ const Packages = () => {
|
|||||||
switch (column.id) {
|
switch (column.id) {
|
||||||
case "name":
|
case "name":
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<button
|
||||||
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
type="button"
|
||||||
<div>
|
onClick={() => navigate(`/packages/${pkg.id}`)}
|
||||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
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}
|
{pkg.name}
|
||||||
</div>
|
</div>
|
||||||
{pkg.description && (
|
{pkg.description && (
|
||||||
@@ -260,33 +352,58 @@ const Packages = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
case "affectedHosts": {
|
case "packageHosts": {
|
||||||
const affectedHostsCount =
|
// Show total number of hosts where this package is installed
|
||||||
pkg.affectedHostsCount || pkg.affectedHosts?.length || 0;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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">
|
<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>
|
</div>
|
||||||
</button>
|
</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 ? (
|
return pkg.isSecurityUpdate ? (
|
||||||
<span className="badge-danger flex items-center gap-1">
|
<span className="badge-danger">
|
||||||
<Shield className="h-3 w-3" />
|
<Shield className="h-3 w-3" />
|
||||||
Security Update
|
Security Update Available
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="badge-warning">Regular Update</span>
|
<span className="badge-warning">Update Available</span>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "latestVersion":
|
case "latestVersion":
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -305,28 +422,30 @@ const Packages = () => {
|
|||||||
const categories =
|
const categories =
|
||||||
[...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || [];
|
[...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || [];
|
||||||
|
|
||||||
// Calculate unique affected hosts
|
// Calculate unique package hosts
|
||||||
const uniqueAffectedHosts = new Set();
|
const uniquePackageHosts = new Set();
|
||||||
packages?.forEach((pkg) => {
|
packages?.forEach((pkg) => {
|
||||||
const affectedHosts = pkg.affectedHosts || [];
|
// Only count hosts for packages that need updates
|
||||||
affectedHosts.forEach((host) => {
|
if ((pkg.stats?.updatesNeeded || 0) > 0) {
|
||||||
uniqueAffectedHosts.add(host.hostId);
|
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 available
|
||||||
const totalPackagesCount =
|
const totalPackagesCount = packages?.length || 0;
|
||||||
hosts?.reduce((total, host) => {
|
|
||||||
return total + (host.totalPackagesCount || 0);
|
|
||||||
}, 0) || 0;
|
|
||||||
|
|
||||||
// Calculate outdated packages (packages that need updates)
|
// Calculate outdated packages
|
||||||
const outdatedPackagesCount = packages?.length || 0;
|
const outdatedPackagesCount =
|
||||||
|
packages?.filter((pkg) => (pkg.stats?.updatesNeeded || 0) > 0).length || 0;
|
||||||
|
|
||||||
// Calculate security updates
|
// Calculate security updates
|
||||||
const securityUpdatesCount =
|
const securityUpdatesCount =
|
||||||
packages?.filter((pkg) => pkg.isSecurityUpdate).length || 0;
|
packages?.filter((pkg) => (pkg.stats?.securityUpdates || 0) > 0).length ||
|
||||||
|
0;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -429,7 +548,7 @@ const Packages = () => {
|
|||||||
Hosts Pending Updates
|
Hosts Pending Updates
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
{uniqueAffectedHostsCount}
|
{uniquePackageHostsCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,16 +609,21 @@ const Packages = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Filter */}
|
{/* Update Status Filter */}
|
||||||
<div className="sm:w-48">
|
<div className="sm:w-48">
|
||||||
<select
|
<select
|
||||||
value={securityFilter}
|
value={updateStatusFilter}
|
||||||
onChange={(e) => setSecurityFilter(e.target.value)}
|
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"
|
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="all-packages">All Packages</option>
|
||||||
<option value="security">Security Only</option>
|
<option value="needs-updates">
|
||||||
<option value="regular">Regular Only</option>
|
Packages Needing Updates
|
||||||
|
</option>
|
||||||
|
<option value="security-updates">
|
||||||
|
Security Updates Only
|
||||||
|
</option>
|
||||||
|
<option value="regular-updates">Regular Updates Only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -539,12 +663,13 @@ const Packages = () => {
|
|||||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
<p className="text-secondary-500 dark:text-secondary-300">
|
<p className="text-secondary-500 dark:text-secondary-300">
|
||||||
{packages?.length === 0
|
{packages?.length === 0
|
||||||
? "No packages need updates"
|
? "No packages found"
|
||||||
: "No packages match your filters"}
|
: "No packages match your filters"}
|
||||||
</p>
|
</p>
|
||||||
{packages?.length === 0 && (
|
{packages?.length === 0 && (
|
||||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +696,7 @@ const Packages = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
{filteredAndSortedPackages.map((pkg) => (
|
{paginatedPackages.map((pkg) => (
|
||||||
<tr
|
<tr
|
||||||
key={pkg.id}
|
key={pkg.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"
|
||||||
@@ -591,6 +716,57 @@ const Packages = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useId, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useTheme } from "../contexts/ThemeContext";
|
import { useTheme } from "../contexts/ThemeContext";
|
||||||
@@ -45,6 +45,18 @@ const Profile = () => {
|
|||||||
last_name: user?.last_name || "",
|
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({
|
const [passwordData, setPasswordData] = useState({
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
newPassword: "",
|
newPassword: "",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Columns,
|
Columns,
|
||||||
Database,
|
Database,
|
||||||
Eye,
|
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Lock,
|
Lock,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -15,21 +14,24 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
Unlock,
|
Unlock,
|
||||||
Users,
|
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { repositoryAPI } from "../utils/api";
|
import { repositoryAPI } from "../utils/api";
|
||||||
|
|
||||||
const Repositories = () => {
|
const Repositories = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [filterType, setFilterType] = useState("all"); // all, secure, insecure
|
const [filterType, setFilterType] = useState("all"); // all, secure, insecure
|
||||||
const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
|
const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
|
||||||
const [sortField, setSortField] = useState("name");
|
const [sortField, setSortField] = useState("name");
|
||||||
const [sortDirection, setSortDirection] = useState("asc");
|
const [sortDirection, setSortDirection] = useState("asc");
|
||||||
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
||||||
|
const [deleteModalData, setDeleteModalData] = useState(null);
|
||||||
|
|
||||||
// Column configuration
|
// Column configuration
|
||||||
const [columnConfig, setColumnConfig] = useState(() => {
|
const [columnConfig, setColumnConfig] = useState(() => {
|
||||||
@@ -80,6 +82,15 @@ const Repositories = () => {
|
|||||||
queryFn: () => repositoryAPI.getStats().then((res) => res.data),
|
queryFn: () => repositoryAPI.getStats().then((res) => res.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete repository mutation
|
||||||
|
const deleteRepositoryMutation = useMutation({
|
||||||
|
mutationFn: (repositoryId) => repositoryAPI.delete(repositoryId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["repositories"]);
|
||||||
|
queryClient.invalidateQueries(["repository-stats"]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Get visible columns in order
|
// Get visible columns in order
|
||||||
const visibleColumns = columnConfig
|
const visibleColumns = columnConfig
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
@@ -138,6 +149,32 @@ const Repositories = () => {
|
|||||||
updateColumnConfig(defaultConfig);
|
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
|
// Filter and sort repositories
|
||||||
const filteredAndSortedRepositories = useMemo(() => {
|
const filteredAndSortedRepositories = useMemo(() => {
|
||||||
if (!repositories) return [];
|
if (!repositories) return [];
|
||||||
@@ -225,6 +262,56 @@ const Repositories = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
<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 */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -415,7 +502,8 @@ const Repositories = () => {
|
|||||||
{filteredAndSortedRepositories.map((repo) => (
|
{filteredAndSortedRepositories.map((repo) => (
|
||||||
<tr
|
<tr
|
||||||
key={repo.id}
|
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) => (
|
{visibleColumns.map((column) => (
|
||||||
<td
|
<td
|
||||||
@@ -513,19 +601,23 @@ const Repositories = () => {
|
|||||||
case "hostCount":
|
case "hostCount":
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
|
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
|
||||||
<Users className="h-4 w-4" />
|
<Server className="h-4 w-4" />
|
||||||
<span>{repo.host_count}</span>
|
<span>{repo.hostCount}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "actions":
|
case "actions":
|
||||||
return (
|
return (
|
||||||
<Link
|
<div className="flex items-center justify-center">
|
||||||
to={`/repositories/${repo.id}`}
|
<button
|
||||||
className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
|
type="button"
|
||||||
>
|
onClick={(e) => handleDeleteRepository(repo, e)}
|
||||||
View
|
className="text-orange-600 hover:text-red-900 dark:text-orange-600 dark:hover:text-red-400 flex items-center gap-1"
|
||||||
<Eye className="h-3 w-3" />
|
disabled={deleteRepositoryMutation.isPending}
|
||||||
</Link>
|
title="Delete repository"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Globe,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
|
Search,
|
||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
|
Trash2,
|
||||||
Unlock,
|
Unlock,
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useId, useState } from "react";
|
import { useId, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { repositoryAPI } from "../utils/api";
|
import { formatRelativeTime, repositoryAPI } from "../utils/api";
|
||||||
|
|
||||||
const RepositoryDetail = () => {
|
const RepositoryDetail = () => {
|
||||||
const isActiveId = useId();
|
const isActiveId = useId();
|
||||||
@@ -24,9 +25,14 @@ const RepositoryDetail = () => {
|
|||||||
const priorityId = useId();
|
const priorityId = useId();
|
||||||
const descriptionId = useId();
|
const descriptionId = useId();
|
||||||
const { repositoryId } = useParams();
|
const { repositoryId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
const [formData, setFormData] = useState({});
|
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
|
// Fetch repository details
|
||||||
const {
|
const {
|
||||||
@@ -39,6 +45,49 @@ const RepositoryDetail = () => {
|
|||||||
enabled: !!repositoryId,
|
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
|
// Update repository mutation
|
||||||
const updateRepositoryMutation = useMutation({
|
const updateRepositoryMutation = useMutation({
|
||||||
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
|
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 = () => {
|
const handleEdit = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: repository.name,
|
name: repository.name,
|
||||||
@@ -68,6 +126,19 @@ const RepositoryDetail = () => {
|
|||||||
setFormData({});
|
setFormData({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
deleteRepositoryMutation.mutate();
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDelete = () => {
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -127,6 +198,56 @@ const RepositoryDetail = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -157,9 +278,6 @@ const RepositoryDetail = () => {
|
|||||||
{repository.is_active ? "Active" : "Inactive"}
|
{repository.is_active ? "Active" : "Inactive"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
|
|
||||||
Repository configuration and host assignments
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -185,15 +303,30 @@ const RepositoryDetail = () => {
|
|||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Repository Information */}
|
{/* 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">
|
<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">
|
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||||
Repository Information
|
Repository Information
|
||||||
@@ -369,80 +502,159 @@ const RepositoryDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hosts Using This Repository */}
|
{/* Hosts Using This Repository */}
|
||||||
<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">
|
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<Users className="h-5 w-5" />
|
<div className="flex items-center gap-3">
|
||||||
Hosts Using This Repository (
|
<Server className="h-5 w-5 text-primary-600" />
|
||||||
{repository.host_repositories?.length || 0})
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
</h2>
|
Hosts Using This Repository ({hosts.length})
|
||||||
</div>
|
</h3>
|
||||||
{!repository.host_repositories ||
|
</div>
|
||||||
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>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
{/* Search */}
|
||||||
{repository.host_repositories.map((hostRepo) => (
|
<div className="relative max-w-sm">
|
||||||
<div
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400" />
|
||||||
key={hostRepo.id}
|
<input
|
||||||
className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
type="text"
|
||||||
>
|
placeholder="Search hosts..."
|
||||||
<div className="flex items-center justify-between">
|
value={searchTerm}
|
||||||
<div className="flex items-center gap-3">
|
onChange={(e) => {
|
||||||
<div
|
setSearchTerm(e.target.value);
|
||||||
className={`w-3 h-3 rounded-full ${
|
setCurrentPage(1);
|
||||||
hostRepo.hosts.status === "active"
|
}}
|
||||||
? "bg-green-500"
|
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"
|
||||||
: hostRepo.hosts.status === "pending"
|
/>
|
||||||
? "bg-yellow-500"
|
</div>
|
||||||
: "bg-red-500"
|
</div>
|
||||||
}`}
|
|
||||||
/>
|
<div className="overflow-x-auto">
|
||||||
<div>
|
{filteredAndPaginatedHosts.length === 0 ? (
|
||||||
<Link
|
<div className="text-center py-8">
|
||||||
to={`/hosts/${hostRepo.hosts.id}`}
|
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
className="text-primary-600 hover:text-primary-700 font-medium"
|
<p className="text-secondary-500 dark:text-secondary-300">
|
||||||
>
|
{searchTerm
|
||||||
{hostRepo.hosts.friendly_name}
|
? "No hosts match your search"
|
||||||
</Link>
|
: "This repository hasn't been reported by any hosts yet."}
|
||||||
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
|
</p>
|
||||||
<span>IP: {hostRepo.hosts.ip}</span>
|
</div>
|
||||||
<span>
|
) : (
|
||||||
OS: {hostRepo.hosts.os_type}{" "}
|
<>
|
||||||
{hostRepo.hosts.os_version}
|
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
</span>
|
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||||
<span>
|
<tr>
|
||||||
Last Update:{" "}
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
{new Date(
|
Host
|
||||||
hostRepo.hosts.last_update,
|
</th>
|
||||||
).toLocaleDateString()}
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
</span>
|
Operating System
|
||||||
</div>
|
</th>
|
||||||
</div>
|
<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>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-center">
|
<button
|
||||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
type="button"
|
||||||
Last Checked
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
</div>
|
disabled={currentPage === 1}
|
||||||
<div className="text-sm text-secondary-900 dark:text-white">
|
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"
|
||||||
{new Date(hostRepo.last_checked).toLocaleDateString()}
|
>
|
||||||
</div>
|
Previous
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Code,
|
Code,
|
||||||
Download,
|
Download,
|
||||||
|
Image,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Server,
|
Server,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Shield,
|
Shield,
|
||||||
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -80,6 +82,15 @@ const Settings = () => {
|
|||||||
});
|
});
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
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
|
// Version checking state
|
||||||
const [versionInfo, setVersionInfo] = useState({
|
const [versionInfo, setVersionInfo] = useState({
|
||||||
currentVersion: null, // Will be loaded from API
|
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
|
// Load current version on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCurrentVersion = async () => {
|
const loadCurrentVersion = async () => {
|
||||||
@@ -556,6 +598,181 @@ const Settings = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Update Interval */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
@@ -1319,6 +1536,18 @@ const Settings = () => {
|
|||||||
error={uploadAgentMutation.error}
|
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>
|
</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;
|
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 SettingsLayout from "../../components/SettingsLayout";
|
||||||
|
import api from "../../utils/api";
|
||||||
|
|
||||||
const Integrations = () => {
|
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 (
|
return (
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
Integrations
|
||||||
Integrations
|
</h1>
|
||||||
</h1>
|
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
Manage auto-enrollment tokens for Proxmox and other integrations
|
||||||
Connect PatchMon to third-party services
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Coming Soon Card */}
|
{/* Tabs Navigation */}
|
||||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg overflow-hidden">
|
||||||
<div className="flex items-center gap-4">
|
<div className="border-b border-secondary-200 dark:border-secondary-600 flex">
|
||||||
<div className="flex-shrink-0">
|
<button
|
||||||
<div className="w-12 h-12 bg-secondary-100 dark:bg-secondary-700 rounded-lg flex items-center justify-center">
|
type="button"
|
||||||
<Plug className="h-6 w-6 text-secondary-700 dark:text-secondary-200" />
|
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>
|
)}
|
||||||
<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>
|
</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>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Code, Server } from "lucide-react";
|
import { Code, Image, Server } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import SettingsLayout from "../../components/SettingsLayout";
|
import SettingsLayout from "../../components/SettingsLayout";
|
||||||
|
import BrandingTab from "../../components/settings/BrandingTab";
|
||||||
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
|
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
|
||||||
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
|
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ const SettingsServerConfig = () => {
|
|||||||
// Set initial tab based on current route
|
// Set initial tab based on current route
|
||||||
if (location.pathname === "/settings/server-version") return "version";
|
if (location.pathname === "/settings/server-version") return "version";
|
||||||
if (location.pathname === "/settings/server-url") return "protocol";
|
if (location.pathname === "/settings/server-url") return "protocol";
|
||||||
|
if (location.pathname === "/settings/branding") return "branding";
|
||||||
if (location.pathname === "/settings/server-config/version")
|
if (location.pathname === "/settings/server-config/version")
|
||||||
return "version";
|
return "version";
|
||||||
return "protocol";
|
return "protocol";
|
||||||
@@ -23,6 +25,8 @@ const SettingsServerConfig = () => {
|
|||||||
setActiveTab("version");
|
setActiveTab("version");
|
||||||
} else if (location.pathname === "/settings/server-url") {
|
} else if (location.pathname === "/settings/server-url") {
|
||||||
setActiveTab("protocol");
|
setActiveTab("protocol");
|
||||||
|
} else if (location.pathname === "/settings/branding") {
|
||||||
|
setActiveTab("branding");
|
||||||
} else if (location.pathname === "/settings/server-config/version") {
|
} else if (location.pathname === "/settings/server-config/version") {
|
||||||
setActiveTab("version");
|
setActiveTab("version");
|
||||||
} else if (location.pathname === "/settings/server-config") {
|
} else if (location.pathname === "/settings/server-config") {
|
||||||
@@ -37,6 +41,12 @@ const SettingsServerConfig = () => {
|
|||||||
icon: Server,
|
icon: Server,
|
||||||
href: "/settings/server-url",
|
href: "/settings/server-url",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "branding",
|
||||||
|
name: "Branding",
|
||||||
|
icon: Image,
|
||||||
|
href: "/settings/branding",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "version",
|
id: "version",
|
||||||
name: "Server Version",
|
name: "Server Version",
|
||||||
@@ -49,6 +59,8 @@ const SettingsServerConfig = () => {
|
|||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case "protocol":
|
case "protocol":
|
||||||
return <ProtocolUrlTab />;
|
return <ProtocolUrlTab />;
|
||||||
|
case "branding":
|
||||||
|
return <BrandingTab />;
|
||||||
case "version":
|
case "version":
|
||||||
return <VersionUpdateTab />;
|
return <VersionUpdateTab />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export const repositoryAPI = {
|
|||||||
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
|
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
|
||||||
update: (repositoryId, data) =>
|
update: (repositoryId, data) =>
|
||||||
api.put(`/repositories/${repositoryId}`, data),
|
api.put(`/repositories/${repositoryId}`, data),
|
||||||
|
delete: (repositoryId) => api.delete(`/repositories/${repositoryId}`),
|
||||||
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
|
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
|
||||||
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
|
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
@@ -259,4 +260,9 @@ export const formatRelativeTime = (date) => {
|
|||||||
return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
|
return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Search API
|
||||||
|
export const searchAPI = {
|
||||||
|
global: (query) => api.get("/search", { params: { q: query } }),
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
117
setup.sh
117
setup.sh
@@ -35,7 +35,7 @@ NC='\033[0m' # No Color
|
|||||||
|
|
||||||
# Global variables
|
# Global variables
|
||||||
SCRIPT_VERSION="self-hosting-install.sh v1.2.7-selfhost-2025-01-20-1"
|
SCRIPT_VERSION="self-hosting-install.sh v1.2.7-selfhost-2025-01-20-1"
|
||||||
DEFAULT_GITHUB_REPO="https://github.com/9technologygroup/patchmon.net.git"
|
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
|
||||||
FQDN=""
|
FQDN=""
|
||||||
CUSTOM_FQDN=""
|
CUSTOM_FQDN=""
|
||||||
EMAIL=""
|
EMAIL=""
|
||||||
@@ -254,7 +254,7 @@ check_prerequisites() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
select_branch() {
|
select_branch() {
|
||||||
print_info "Fetching available branches from GitHub repository..."
|
print_info "Fetching available releases from GitHub repository..."
|
||||||
|
|
||||||
# Create temporary directory for git operations
|
# Create temporary directory for git operations
|
||||||
TEMP_DIR="/tmp/patchmon_branches_$$"
|
TEMP_DIR="/tmp/patchmon_branches_$$"
|
||||||
@@ -263,84 +263,88 @@ select_branch() {
|
|||||||
|
|
||||||
# Try to clone the repository normally
|
# Try to clone the repository normally
|
||||||
if git clone "$DEFAULT_GITHUB_REPO" . 2>/dev/null; then
|
if git clone "$DEFAULT_GITHUB_REPO" . 2>/dev/null; then
|
||||||
# Get list of remote branches and trim whitespace
|
# Get list of tags sorted by version (semantic versioning)
|
||||||
branches=$(git branch -r | grep -v HEAD | sed 's/origin\///' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//' | sort -u)
|
# Using git tag with version sorting
|
||||||
|
tags=$(git tag -l --sort=-v:refname 2>/dev/null | head -3)
|
||||||
|
|
||||||
if [ -n "$branches" ]; then
|
if [ -n "$tags" ]; then
|
||||||
print_info "Available branches with details:"
|
print_info "Available releases and branches:"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Get branch information
|
# Display last 3 release tags
|
||||||
branch_count=1
|
option_count=1
|
||||||
while IFS= read -r branch; do
|
declare -A options_map
|
||||||
if [ -n "$branch" ]; then
|
|
||||||
# Get last commit date for this branch
|
while IFS= read -r tag; do
|
||||||
last_commit=$(git log -1 --format="%ci" "origin/$branch" 2>/dev/null || echo "Unknown")
|
if [ -n "$tag" ]; then
|
||||||
|
# Get tag date and commit info
|
||||||
# Get release tag associated with this branch (if any)
|
tag_date=$(git log -1 --format="%ci" "$tag" 2>/dev/null || echo "Unknown")
|
||||||
release_tag=$(git describe --tags --exact-match "origin/$branch" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
# Format the date
|
# Format the date
|
||||||
if [ "$last_commit" != "Unknown" ]; then
|
if [ "$tag_date" != "Unknown" ]; then
|
||||||
formatted_date=$(date -d "$last_commit" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$last_commit")
|
formatted_date=$(date -d "$tag_date" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$tag_date")
|
||||||
else
|
else
|
||||||
formatted_date="Unknown"
|
formatted_date="Unknown"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Display branch info
|
# Mark the first one as latest
|
||||||
printf "%2d. %-20s" "$branch_count" "$branch"
|
if [ $option_count -eq 1 ]; then
|
||||||
printf " (Last commit: %s)" "$formatted_date"
|
printf "%2d. %-20s (Latest Release - %s)\n" "$option_count" "$tag" "$formatted_date"
|
||||||
|
else
|
||||||
if [ -n "$release_tag" ]; then
|
printf "%2d. %-20s (Release - %s)\n" "$option_count" "$tag" "$formatted_date"
|
||||||
printf " [Release: %s]" "$release_tag"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
# Store the tag for later selection
|
||||||
branch_count=$((branch_count + 1))
|
options_map[$option_count]="$tag"
|
||||||
|
option_count=$((option_count + 1))
|
||||||
fi
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# Determine default selection: prefer 'main' if present
|
# Default to option 1 (latest release tag)
|
||||||
main_index=$(echo "$branches" | nl -w1 -s':' | awk -F':' '$2=="main"{print $1}' | head -1)
|
default_option=1
|
||||||
if [ -z "$main_index" ]; then
|
|
||||||
main_index=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
while true; do
|
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
|
if [[ "$SELECTION_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||||
selected_branch=$(echo "$branches" | sed -n "${BRANCH_NUMBER}p" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
|
selected_option="${options_map[$SELECTION_NUMBER]}"
|
||||||
if [ -n "$selected_branch" ]; then
|
if [ -n "$selected_option" ]; then
|
||||||
DEPLOYMENT_BRANCH="$selected_branch"
|
DEPLOYMENT_BRANCH="$selected_option"
|
||||||
|
|
||||||
# Show additional info for selected branch
|
# Show confirmation
|
||||||
last_commit=$(git log -1 --format="%ci" "origin/$selected_branch" 2>/dev/null || echo "Unknown")
|
if [ "$selected_option" = "main" ]; then
|
||||||
release_tag=$(git describe --tags --exact-match "origin/$selected_branch" 2>/dev/null || echo "")
|
print_status "Selected branch: main (latest development code)"
|
||||||
|
print_info "Last commit: $formatted_main_date"
|
||||||
if [ "$last_commit" != "Unknown" ]; then
|
|
||||||
formatted_date=$(date -d "$last_commit" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$last_commit")
|
|
||||||
else
|
else
|
||||||
formatted_date="Unknown"
|
print_status "Selected release: $selected_option"
|
||||||
fi
|
tag_date=$(git log -1 --format="%ci" "$selected_option" 2>/dev/null || echo "Unknown")
|
||||||
|
if [ "$tag_date" != "Unknown" ]; then
|
||||||
print_status "Selected branch: $DEPLOYMENT_BRANCH"
|
formatted_date=$(date -d "$tag_date" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$tag_date")
|
||||||
print_info "Last commit: $formatted_date"
|
print_info "Release date: $formatted_date"
|
||||||
if [ -n "$release_tag" ]; then
|
fi
|
||||||
print_info "Release tag: $release_tag"
|
|
||||||
fi
|
fi
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
print_error "Invalid branch number. Please try again."
|
print_error "Invalid selection number. Please try again."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
print_error "Please enter a valid number."
|
print_error "Please enter a valid number."
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
print_warning "No branches found, using default: main"
|
print_warning "No release tags found, using default: main"
|
||||||
DEPLOYMENT_BRANCH="main"
|
DEPLOYMENT_BRANCH="main"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
@@ -789,9 +793,13 @@ create_env_files() {
|
|||||||
cat > backend/.env << EOF
|
cat > backend/.env << EOF
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
|
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 Configuration
|
||||||
JWT_SECRET="$JWT_SECRET"
|
JWT_SECRET="$JWT_SECRET"
|
||||||
|
JWT_EXPIRES_IN=1h
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=$BACKEND_PORT
|
PORT=$BACKEND_PORT
|
||||||
@@ -803,6 +811,12 @@ API_VERSION=v1
|
|||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
CORS_ORIGIN="$SERVER_PROTOCOL_SEL://$FQDN"
|
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 Limiting (times in milliseconds)
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
RATE_LIMIT_MAX=5000
|
RATE_LIMIT_MAX=5000
|
||||||
@@ -813,6 +827,7 @@ AGENT_RATE_LIMIT_MAX=1000
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
ENABLE_LOGGING=true
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Frontend .env
|
# Frontend .env
|
||||||
|
|||||||
Reference in New Issue
Block a user