22 Commits

Author SHA1 Message Date
Ducky SSH User
7d5713ed36 chore: remove build artifacts from version control
All checks were successful
Build and Release / build (push) Successful in 15s
- Remove agent and server binaries from git tracking
- These files are now properly ignored via .gitignore
2025-12-20 08:09:26 +00:00
Ducky SSH User
cc26726ddf chore: update .gitignore to exclude temporary build files
- Add agent and server binaries to .gitignore
- Remove temporary build artifacts from repository
2025-12-20 08:09:17 +00:00
Ducky SSH User
8cb33dbc90 feat: implement real-time updates and enhance monitoring system
- Add structured logging with slog throughout application
- Implement real-time updates using Server-Sent Events and HTMX
- Add broadcaster system for instant UI updates when agents report stats
- Replace meta refresh with HTMX-powered seamless updates
- Add new API endpoints for HTMX fragments and SSE events
- Update templates to use HTMX for instant data refresh
- Enhance README with real-time features and updated documentation
- Remove obsolete template generation file
2025-12-20 08:09:02 +00:00
Ducky SSH User
50dcfcdc83 Add logging and fix /agents/ route error
All checks were successful
Build and Release / build (push) Successful in 35s
2025-12-20 07:34:02 +00:00
Ducky SSH User
761b91b031 Add hostname editing feature to agent detail page
- Create enhanced agent detail template with all stats and hostname edit form
- Add UpdateHostname method to store for agent hostname updates
- Add UpdateAgentHostname handler in UI for web form submissions
- Add UpdateHostname endpoint in API handler for JSON requests
- Register new POST /agents/{id}/hostname route for hostname updates
- Improve agent detail page layout with Settings and Danger Zone sections
- Add error handling for missing agents and empty hostname values
2025-12-20 06:51:27 +00:00
Ducky SSH User
0a37b04506 Fix Alpine Linux addgroup/adduser syntax for non-root user creation
All checks were successful
Build and Release / build (push) Successful in 12s
2025-12-20 06:45:21 +00:00
Ducky SSH User
a5a683d1de Fix Docker Compose and Dockerfile issues: remove image pull, fix entrypoint permissions 2025-12-20 06:36:20 +00:00
Ducky SSH User
e0b8f8650b Fix wget --post-file usage with temporary files for API calls
All checks were successful
Build and Release / build (push) Successful in 11s
2025-12-20 06:31:33 +00:00
Ducky SSH User
3a7b5a0f9a Replace curl with wget in CI/CD workflow for Gitea runner compatibility 2025-12-20 06:31:02 +00:00
Ducky SSH User
999a595b9c Remove jq dependency from release workflow
Some checks failed
Build and Release / build (push) Failing after 12s
- Replace jq with grep for parsing JSON responses
- Use curl -w to capture HTTP response codes
- Improve error handling and logging
- Check HTTP response codes for upload success
- Should work on runners without jq installed
- Fixes 'command not found' error on release creation
2025-12-20 06:28:25 +00:00
Ducky SSH User
e6f705486d Fix git tag checkout in Gitea Actions workflow
Some checks failed
Build and Release / build (push) Failing after 10s
- For tags: fetch with explicit ref mapping to refs/tags/
- For branches: fetch with explicit ref mapping to refs/remotes/origin/
- Properly checkout tag refs using refs/tags/ path
- Fixes 'pathspec did not match any file(s)' error on tag builds
2025-12-20 06:24:56 +00:00
Ducky SSH User
66734923cb Update GITEA_SETUP.md for binary-only CI/CD pipeline
Some checks failed
Build and Release / build (push) Failing after 1s
- Clarify Docker is not required for CI/CD
- Note that Docker images are built manually
- Simplify troubleshooting section
- Remove Docker setup instructions from setup guide
- Link to manual Docker build documentation
2025-12-20 06:21:37 +00:00
Ducky SSH User
48d2d7f83d Update RELEASE.md to reflect binary-only CI/CD pipeline
- Remove references to Docker image CI/CD builds
- Clarify Docker images are built manually or with docker-compose
- Simplify deployment options (binaries, Docker, Docker Compose)
- Update troubleshooting to focus on binary releases
- Remove Docker-specific troubleshooting steps
- Keep Dockerfiles for manual builds
2025-12-20 06:21:18 +00:00
Ducky SSH User
444bda7263 Remove Docker-specific runner documentation
- Remove GITEA_RUNNER_DOCKER.md (no longer needed)
- Docker builds removed from CI/CD pipeline
- Dockerfiles and docker-compose remain for manual builds
2025-12-20 06:20:35 +00:00
Ducky SSH User
99fc1a28ad Simplify CI/CD pipeline to focus on binary releases only
- Remove docker-build job entirely
- Keep focus on cross-platform binary builds (Linux, macOS, Windows)
- Generate SHA256 checksums for all binaries
- Upload binaries and checksums to Gitea releases
- Keep Dockerfiles and docker-compose for manual builds
- Much simpler and more reliable workflow
2025-12-20 06:20:32 +00:00
Ducky SSH User
2075cd2901 Fix Docker builds in Gitea runner with proper Docker-in-Docker detection
- Use 'docker info' instead of 'command -v docker' for reliable detection
- Add 30-second wait for Docker daemon startup (for DinD startup delay)
- Improve Docker build step with better error handling
- Build Docker images when available, skip gracefully if not
- Add comprehensive GITEA_RUNNER_DOCKER.md setup guide
- Document Docker socket mounting for runners
- Include troubleshooting and complete docker-compose example
2025-12-20 06:16:10 +00:00
Ducky SSH User
f4ec33fe53 Update GITEA_SETUP.md with optional Docker configuration
- Document Docker installation on runner for image builds
- Clarify that Docker is optional (not required for binary builds)
- Add instructions for running the runner user with Docker permissions
- Update troubleshooting section with Docker-specific guidance
- Explain graceful handling when Docker is not available
- Add Docker installation link in support section
2025-12-20 06:13:53 +00:00
Ducky SSH User
3080cb1e87 Make Docker build optional in Gitea Actions workflow
- Check if Docker is available before attempting to use it
- Skip Docker image builds gracefully if Docker is not installed
- Provide helpful instructions for enabling Docker support
- Add error handling for Docker build failures
- Allow workflow to succeed even without Docker
- Binary builds will still complete successfully
2025-12-20 06:13:34 +00:00
Ducky SSH User
89fb5bbf7d Fix git checkout for tag builds in Gitea Actions
Some checks failed
Build and Release / build (push) Failing after 0s
Build and Release / docker-build (push) Failing after 0s
- Add conditional logic to handle tag vs branch checkouts
- Tags don't exist on 'origin/tagname' so use direct checkout
- Branches can be checked out as 'origin/branchname'
- Fixes 'fatal: origin/v0.0.1 is not a commit' error
2025-12-20 06:09:43 +00:00
Ducky SSH User
6c6bc0d57f Add Gitea server setup guide
Some checks failed
Build and Release / build (push) Failing after 0s
Build and Release / docker-build (push) Failing after 0s
- Document GITEA_TOKEN secret configuration
- Provide API token creation instructions
- Include Gitea Actions enablement steps
- Add runner verification and setup instructions
- Include testing procedures for the complete setup
- Add troubleshooting for common configuration issues
- Explain the workflow execution flow after setup
2025-12-20 06:06:21 +00:00
Ducky SSH User
3dbd60ac27 Update RELEASE.md with Gitea Actions setup and troubleshooting
- Add detailed Gitea configuration instructions
- Document how to create and configure API tokens as repository secrets
- Explain the automated release workflow step-by-step
- List all artifacts created during release (binaries, checksums, Docker images)
- Add comprehensive troubleshooting section with solutions
- Include workflow monitoring and log inspection guide
- Add information about manual trigger testing
2025-12-20 06:06:04 +00:00
Ducky SSH User
5664105111 Fix Gitea Actions workflow for compatibility
- Replace GitHub Actions with native Gitea Actions syntax
- Remove dependency on Node.js-based actions
- Use manual git checkout instead of actions/checkout
- Download Go directly instead of using actions/setup-go
- Use native curl/bash for release creation and uploads
- Support both main and master branch pushes
- Simplify Docker image building without external actions
- Add proper Gitea API token handling for releases
2025-12-20 06:05:29 +00:00
19 changed files with 1185 additions and 360 deletions

View File

@@ -3,6 +3,7 @@ name: Build and Release
on:
push:
branches:
- main
- master
tags:
- 'v*'
@@ -12,129 +13,145 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
run: |
cd $GITHUB_WORKSPACE
git init
git remote add origin ${{ github.server_url }}/${{ github.repository }}.git
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
# For tags, fetch the specific tag and checkout the commit it points to
git fetch origin ${{ github.ref }}:refs/tags/${{ github.ref_name }}
git checkout refs/tags/${{ github.ref_name }}
else
# For branches, fetch and checkout with tracking
git fetch origin ${{ github.ref_name }}:refs/remotes/origin/${{ github.ref_name }}
git checkout -b ${{ github.ref_name }} origin/${{ github.ref_name }}
fi
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24.4'
run: |
wget https://go.dev/dl/go1.24.4.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
go version
- name: Generate version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
VERSION=${{ github.ref_name }}
else
VERSION=dev-${{ github.sha }}
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_ENV
- name: Build all binaries
run: |
export PATH=$PATH:/usr/local/go/bin
mkdir -p bin
# Generate templ first
go run github.com/a-h/templ/cmd/templ@latest generate
# Linux AMD64
echo "Building Linux AMD64..."
GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o bin/nerd-monitor-server-linux-amd64 ./cmd/server
GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o bin/nerd-monitor-agent-linux-amd64 ./cmd/agent
# Linux ARM64
echo "Building Linux ARM64..."
GOOS=linux GOARCH=arm64 go build -ldflags="-w -s" -o bin/nerd-monitor-server-linux-arm64 ./cmd/server
GOOS=linux GOARCH=arm64 go build -ldflags="-w -s" -o bin/nerd-monitor-agent-linux-arm64 ./cmd/agent
# macOS AMD64
echo "Building macOS AMD64..."
GOOS=darwin GOARCH=amd64 go build -ldflags="-w -s" -o bin/nerd-monitor-server-darwin-amd64 ./cmd/server
GOOS=darwin GOARCH=amd64 go build -ldflags="-w -s" -o bin/nerd-monitor-agent-darwin-amd64 ./cmd/agent
# macOS ARM64
echo "Building macOS ARM64..."
GOOS=darwin GOARCH=arm64 go build -ldflags="-w -s" -o bin/nerd-monitor-server-darwin-arm64 ./cmd/server
GOOS=darwin GOARCH=arm64 go build -ldflags="-w -s" -o bin/nerd-monitor-agent-darwin-arm64 ./cmd/agent
# Windows AMD64
echo "Building Windows AMD64..."
GOOS=windows GOARCH=amd64 go build -ldflags="-w -s" -o bin/nerd-monitor-server-windows-amd64.exe ./cmd/server
GOOS=windows GOARCH=amd64 go build -ldflags="-w -s" -o bin/nerd-monitor-agent-windows-amd64.exe ./cmd/agent
echo "Build complete! Files:"
ls -lh bin/
- name: Create checksums
run: |
cd bin
sha256sum * > SHA256SUMS
cd ..
echo "Checksums:"
cat bin/SHA256SUMS
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: binaries-${{ steps.version.outputs.version }}
path: bin/
retention-days: 30
- name: Create Release
if: startsWith(github.ref, 'refs/tags/')
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
- name: Upload release assets
- name: Create Release and Upload
if: startsWith(github.ref, 'refs/tags/')
run: |
# This step uploads binaries to the release
# Note: Gitea Actions may require additional configuration
export GITEA_TOKEN="${{ secrets.GITEA_TOKEN }}"
export GITEA_URL="${{ github.server_url }}"
export REPO_OWNER="${{ github.repository_owner }}"
export REPO_NAME="${{ github.repository }}"
export REPO_NAME=${REPO_NAME#*/}
TAG=${{ github.ref_name }}
echo "Creating release for tag: $TAG"
echo "Repository: $REPO_OWNER/$REPO_NAME"
# Create release using Gitea API with wget
echo "Creating new release..."
# Create JSON payload in a temp file
cat > /tmp/release.json << 'PAYLOAD'
{"tag_name":"TAG_PLACEHOLDER","name":"Release TAG_PLACEHOLDER","draft":false,"prerelease":false}
PAYLOAD
sed -i "s/TAG_PLACEHOLDER/$TAG/g" /tmp/release.json
RESPONSE=$(wget --post-file=/tmp/release.json \
--header="Authorization: token $GITEA_TOKEN" \
--header="Content-Type: application/json" \
-O - -q \
"$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases")
# Extract release ID using grep
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release. Response:"
echo "$RESPONSE"
exit 1
fi
echo "Created release ID: $RELEASE_ID"
# Upload all binaries
echo "Uploading release artifacts..."
for file in bin/*; do
if [ -f "$file" ]; then
echo "Uploading $file to release"
filename=$(basename "$file")
echo " Uploading: $filename"
# Upload binary file to Gitea API
UPLOAD_RESPONSE=$(wget --post-file="$file" \
--header="Authorization: token $GITEA_TOKEN" \
--header="Content-Type: application/octet-stream" \
-O - -q \
"$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases/$RELEASE_ID/assets?name=$filename" 2>&1)
if echo "$UPLOAD_RESPONSE" | grep -q '"id"'; then
echo " ✓ $filename uploaded"
else
echo " ✗ Failed to upload $filename"
echo "Response: $UPLOAD_RESPONSE"
fi
fi
done
docker-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION=dev-${{ github.sha }}
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Build and push server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.server
push: false
outputs: type=docker,dest=/tmp/nerd-monitor-server.tar
tags: |
nerd-monitor-server:latest
nerd-monitor-server:${{ steps.version.outputs.version }}
- name: Build and push agent image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.agent
push: false
outputs: type=docker,dest=/tmp/nerd-monitor-agent.tar
tags: |
nerd-monitor-agent:latest
nerd-monitor-agent:${{ steps.version.outputs.version }}
- name: Upload Docker images
uses: actions/upload-artifact@v4
with:
name: docker-images-${{ steps.version.outputs.version }}
path: /tmp/nerd-monitor-*.tar
retention-days: 30
echo ""
echo "Release completed!"
echo "View at: $GITEA_URL/$REPO_OWNER/$REPO_NAME/releases/tag/$TAG"

4
.gitignore vendored
View File

@@ -31,3 +31,7 @@ Thumbs.db
# Local configuration
config.local.yaml
.env.*.local
# Temporary build files
agent
server

View File

@@ -1,42 +1,20 @@
# Nerd Monitor - Agent Guidelines
## Build & Test Commands
- **Generate templates**: `make templ`
- **Build**: `make build` (server + agent) or `make build-server`/`make build-agent`
- **Cross-platform**: `make build-all`
- **Clean**: `make clean`
- **Test all**: `go test ./...`
- **Test single**: `go test -run TestName ./package`
- **Format**: `gofmt -w ./cmd ./internal ./views`
```bash
# Generate Templ templates (required before building)
make templ
# Build server and agent for current OS
make build
# Build individual components
make build-server
make build-agent
# Build for all platforms (Linux, macOS, Windows)
make build-all
# Clean build artifacts
make clean
```
Note: This Go 1.21 project uses Templ for templating. Always run `make templ` before building.
Note: Go 1.23+ project using Templ templating. Run `make templ` before building.
## Code Style Guidelines
**Imports**: Standard library first, then third-party packages, then internal modules. Organize alphabetically within each group.
**Formatting & Types**: Use `gofmt` standards. Explicit struct field comments for exported types. Prefer `error` return values (no panic for recoverable errors).
**Naming**: CamelCase for exported symbols, lowercase for unexported. Receiver names as single letters (e.g., `(m *Manager)`). Constants use ALL_CAPS_SNAKE_CASE.
**Error Handling**: Define sentinel errors in dedicated files (e.g., `auth/errors.go`). Check errors explicitly; use `http.Error(w, "message", status)` for HTTP responses. Wrap errors with context when needed.
**Project Structure**:
- `cmd/`: Executable entry points (server, agent)
- `internal/`: Packages (api, auth, stats, store, ui)
- `views/`: Templ templates (*.templ files)
**Concurrency**: Use `sync.RWMutex` for shared state. Always defer lock releases. Keep critical sections small.
**HTTP**: Use Chi router. Handlers follow `func (h *Handler) Name(w http.ResponseWriter, r *http.Request)`. Set Content-Type headers explicitly. Validate query parameters early and return 400/404 appropriately.
- **Imports**: stdlib → third-party → internal (alphabetical within groups)
- **Formatting**: `gofmt` standards, explicit exported struct comments
- **Naming**: CamelCase exports, lowercase unexported, single-letter receivers (e.g., `(h *Handler)`)
- **Error Handling**: Explicit checks, `http.Error()` for HTTP, contextual wrapping
- **Concurrency**: `sync.RWMutex` for shared state, defer lock releases, small critical sections
- **HTTP**: Chi router, `func (h *Handler) Method(w http.ResponseWriter, r *http.Request)`, set Content-Type, validate early

View File

@@ -25,21 +25,21 @@ WORKDIR /app
COPY --from=builder /app/nerd-monitor-agent .
# Create non-root user
RUN addgroup -D appgroup && adduser -D appuser -G appgroup
USER appuser
# Create entrypoint script BEFORE switching users
RUN echo '#!/bin/sh' > /app/entrypoint.sh && \
echo 'SERVER=${SERVER:-localhost:8080}' >> /app/entrypoint.sh && \
echo 'INTERVAL=${INTERVAL:-15s}' >> /app/entrypoint.sh && \
echo 'AGENT_ID=${AGENT_ID:-}' >> /app/entrypoint.sh && \
echo 'if [ -z "$AGENT_ID" ]; then' >> /app/entrypoint.sh && \
echo ' exec ./nerd-monitor-agent --server "$SERVER" --interval "$INTERVAL"' >> /app/entrypoint.sh && \
echo 'else' >> /app/entrypoint.sh && \
echo ' exec ./nerd-monitor-agent --server "$SERVER" --interval "$INTERVAL" --id "$AGENT_ID"' >> /app/entrypoint.sh && \
echo 'fi' >> /app/entrypoint.sh && \
chmod +x /app/entrypoint.sh
# Create entrypoint script to handle environment variables
RUN echo '#!/bin/sh\n\
SERVER=${SERVER:-localhost:8080}\n\
INTERVAL=${INTERVAL:-15s}\n\
AGENT_ID=${AGENT_ID:-}\n\
if [ -z "$AGENT_ID" ]; then\n\
exec ./nerd-monitor-agent --server "$SERVER" --interval "$INTERVAL"\n\
else\n\
exec ./nerd-monitor-agent --server "$SERVER" --interval "$INTERVAL" --id "$AGENT_ID"\n\
fi\n\
' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Create non-root user
RUN addgroup -g 1000 appgroup && adduser -D -u 1000 -G appgroup appuser
USER appuser
# Run the agent
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -32,8 +32,17 @@ RUN apk add --no-cache ca-certificates
# Copy binary from builder
COPY --from=builder /app/nerd-monitor-server .
# Create entrypoint script BEFORE switching users
RUN echo '#!/bin/sh' > /app/entrypoint.sh && \
echo 'ADDR=${ADDR:-0.0.0.0}' >> /app/entrypoint.sh && \
echo 'PORT=${PORT:-8080}' >> /app/entrypoint.sh && \
echo 'USERNAME=${USERNAME:-admin}' >> /app/entrypoint.sh && \
echo 'PASSWORD=${PASSWORD:-admin}' >> /app/entrypoint.sh && \
echo 'exec ./nerd-monitor-server -addr "$ADDR" -port "$PORT" -username "$USERNAME" -password "$PASSWORD"' >> /app/entrypoint.sh && \
chmod +x /app/entrypoint.sh
# Create non-root user
RUN addgroup -D appgroup && adduser -D appuser -G appgroup
RUN addgroup -g 1000 appgroup && adduser -D -u 1000 -G appgroup appuser
USER appuser
# Expose port
@@ -43,14 +52,5 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/login || exit 1
# Create entrypoint script to handle environment variables
RUN echo '#!/bin/sh\n\
ADDR=${ADDR:-0.0.0.0}\n\
PORT=${PORT:-8080}\n\
USERNAME=${USERNAME:-admin}\n\
PASSWORD=${PASSWORD:-admin}\n\
exec ./nerd-monitor-server -addr "$ADDR" -port "$PORT" -username "$USERNAME" -password "$PASSWORD"\n\
' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Run the server
ENTRYPOINT ["/app/entrypoint.sh"]

164
GITEA_SETUP.md Normal file
View File

@@ -0,0 +1,164 @@
# Gitea Actions Setup - Server Configuration Checklist
This document outlines the changes needed on your Gitea server to make the CI/CD pipeline work.
## What You Need to Do
### 1. Add GITEA_TOKEN Secret to Repository
1. **Log into Gitea** with your user account
2. **Navigate to** your nerd-monitor repository
3. **Go to Settings → Secrets**
4. **Create a new secret**:
- Name: `GITEA_TOKEN`
- Value: [Your Gitea API token - see below for how to create]
5. **Save the secret**
### 2. Create an API Token (if you haven't already)
1. **Log into Gitea** with your user account
2. **Go to Settings → Applications**
3. **Click "Generate New Token"**
4. **Fill in the form**:
- Token Name: `release-automation`
- Scopes: Select at least `repo` (full repository access)
5. **Click "Generate"**
6. **Copy the token immediately** (you won't see it again)
7. **Use this token** for the secret in step 1
### 3. Verify Gitea Actions is Enabled (Server Admin)
These steps require SSH access to your Gitea server:
```bash
# SSH into your Gitea server
ssh user@your-gitea-server
# Edit the Gitea configuration
sudo vi /etc/gitea/app.ini
# Verify or add this section:
[actions]
ENABLED = true
# Save and exit (Esc, :wq, Enter)
# Restart Gitea for changes to take effect
sudo systemctl restart gitea
```
### 4. Verify Your Runner is Online (Server Admin)
1. **Log into Gitea** as admin
2. **Go to Administration → Actions → Runners**
3. **Verify** at least one runner is listed and shows as "Online" or "Idle"
4. If no runners:
- You need to set up a Gitea Actions runner on a machine with Docker and Go
- See "Setting Up a Runner" below
## Setting Up a Runner (if needed)
If you don't have any runners yet, you need to set one up. This can be on the Gitea server itself or any machine with Docker and Go.
### Quick Runner Setup
1. **On your Gitea server or runner machine**:
```bash
# Download the Gitea Actions runner
wget https://github.com/gitea/act_runner/releases/download/v0.6.10/act_runner-0.6.10-linux-amd64
chmod +x act_runner-0.6.10-linux-amd64
# Register the runner with your Gitea instance
./act_runner-0.6.10-linux-amd64 register \
--instance https://git.nerdnest.dev \
--token <your-runner-token>
# Run the runner in the background
./act_runner-0.6.10-linux-amd64 daemon &
```
To get a runner token:
1. Log into Gitea as **admin**
2. Go to **Administration → Actions → Runners**
3. Click **Create new runner**
4. Follow the registration steps
### Installing Docker on the Runner (Optional)
Docker is **not required** for the CI/CD pipeline. Binary builds work without Docker.
If you want to manually build Docker images:
```bash
# Build server image
docker build -t nerd-monitor-server -f Dockerfile.server .
# Build agent image
docker build -t nerd-monitor-agent -f Dockerfile.agent .
# Or use docker-compose for both
docker-compose build
docker-compose up
```
## What Happens Next
Once you've set everything up:
1. **Every push to main/master** triggers the build job:
- Compiles all platform binaries (Linux, macOS, Windows)
- Creates SHA256 checksums
- Artifacts available for 30 days
2. **Every git tag push** (e.g., `v1.0.0`) triggers the full release:
- Does all of the above
- Creates a Gitea Release
- Uploads all binaries and checksums to the release (permanent storage)
3. **Releases are available in**:
- Repository Releases tab in Gitea
- All binaries ready for download
- SHA256SUMS file for verification
Note: Docker images are not built automatically. To build Docker images manually, use:
```bash
docker build -t nerd-monitor-server -f Dockerfile.server .
docker build -t nerd-monitor-agent -f Dockerfile.agent .
# Or use docker-compose: docker-compose build
```
## Troubleshooting
### "docker: command not found" in workflow
- Docker support is not required - the workflow builds binaries without it
- Binary builds will always succeed
- Docker images must be built manually using: `docker build -f Dockerfile.server .`
- See QUICKSTART.md or DOCKER_COMPOSE.md for manual Docker build instructions
### "Action not found" error
- Make sure Gitea Actions is enabled
- Restart Gitea if you just enabled it: `sudo systemctl restart gitea`
### No runners available
- Runner must be registered: Administration → Actions → Runners
- Runner machine must have Go installed (1.24.4 or later)
- Docker is optional (for building Docker images)
- Check if runner is online in the UI
### "GITEA_TOKEN" not found
- Make sure the secret is named exactly `GITEA_TOKEN` (case-sensitive)
- Go to Settings → Secrets and verify it's there
- If it's there, try re-running the workflow
### Build fails with permission denied (Docker)
- Make sure the runner user has permission to run Docker commands
- On the runner machine: `sudo usermod -aG docker $USER`
- Logout and log back in for the group change to take effect
## Support
- Gitea Actions Docs: https://docs.gitea.io/en-us/actions/
- Act Runner Docs: https://gitea.com/gitea/act_runner
- Docker Installation: https://docs.docker.com/install/
- For issues with the workflow itself, check the Actions tab logs

108
README.md
View File

@@ -1,22 +1,25 @@
# Nerd Monitor 📊
A lightweight, cross-platform system monitoring solution written in Go. Monitor CPU, memory, and disk usage across multiple machines with a beautiful web dashboard.
A lightweight, cross-platform system monitoring solution written in Go. Monitor CPU, memory, and disk usage across multiple machines with a beautiful web dashboard featuring instant real-time updates powered by Server-Sent Events and HTMX.
![Go](https://img.shields.io/badge/Go-1.23-blue?logo=go)
![Real-time](https://img.shields.io/badge/real--time-SSE%20%2B%20HTMX-orange?logo=lightning)
![License](https://img.shields.io/badge/license-MIT-green)
![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20macOS%20%7C%20Windows-important)
## Features
- 🖥️ **Multi-platform Support** - Deploy agents on Linux, macOS, and Windows (AMD64 & ARM64)
- 📈 **Real-time Monitoring** - Track CPU, RAM, and Disk usage with 15-second refresh intervals
- **Instant Real-time Updates** - Stats update immediately when agents report, not on fixed intervals
- 🟢 **Live Status Indicators** - See which machines are online/offline at a glance
- 🔒 **Secure Authentication** - Session-based admin authentication for the dashboard
- 🧹 **Stale Agent Management** - Automatically detect and remove agents inactive for 6+ months
- 📱 **Responsive Dashboard** - Beautiful, modern UI with auto-refresh
- 🚀 **Minimal Dependencies** - Only Chi router and Templ templating engine (plus Go stdlib)
- 📱 **Responsive Dashboard** - Beautiful, modern UI with seamless HTMX-powered updates
- 🚀 **Minimal Dependencies** - Only Chi router, Templ templating, and HTMX (plus Go stdlib)
- ⚙️ **Auto-Generation** - Agent IDs automatically generated from hostname
- 💾 **In-Memory Storage** - Fast, zero-database setup (perfect for small to medium deployments)
- 📡 **Server-Sent Events** - Real-time push notifications from server to web clients
- 📝 **Structured Logging** - Comprehensive slog-based logging throughout the application
## Quick Start
@@ -54,7 +57,7 @@ Binaries are created in the `bin/` directory.
-password securepassword
```
Then access the dashboard at `http://localhost:8080`
Then access the dashboard at `http://localhost:8080`. The interface provides real-time updates - stats refresh instantly when agents report new data!
### Running Agents
@@ -87,7 +90,7 @@ nerd-monitor-agent.bat --server 10.0.20.80:9090 --interval 30s
### Overview Page
Shows all connected agents with real-time metrics:
Shows all connected agents with instant real-time metrics:
- **Status Badge** - Green (Online) or Red (Offline)
- Online: Last report within 15 seconds
@@ -96,16 +99,18 @@ Shows all connected agents with real-time metrics:
- **Memory Usage** - Used / Total with visual progress bar
- **Disk Usage** - Used / Total with visual progress bar
- **Last Seen** - Human-readable timestamp
- **Live Updates** - Stats refresh immediately when agents report new data
### Agent Detail Page
Click any agent hostname to see detailed statistics:
Click any agent hostname to see detailed statistics with live updates:
- Large CPU usage display with progress bar
- Large CPU usage display with progress bar (updates in real-time)
- Memory and disk breakdowns
- Agent ID and detailed metadata
- Last exact timestamp
- Delete button for removing the agent
- **Instant Updates** - CPU stats refresh immediately when agent reports
### Stale Agent Management
@@ -122,10 +127,15 @@ Agents inactive for 6+ months:
- **HTTP Router**: Chi v5 for efficient routing
- **Authentication**: Session-based auth with 24-hour expiry
- **Storage**: In-memory concurrent-safe store (sync.RWMutex)
- **Real-time Broadcasting**: Server-Sent Events (SSE) for instant UI updates
- **Structured Logging**: slog-based logging with debug/info levels
- **API Endpoints**:
- `POST /api/report` - Agent stats reporting
- `POST /api/report` - Agent stats reporting (triggers real-time broadcasts)
- `GET /api/agents` - List all agents
- `GET /api/agents/{id}` - Get specific agent stats
- `GET /api/dashboard/table` - HTML fragment for HTMX dashboard updates
- `GET /api/agents/{id}/stats` - HTML fragment for HTMX agent detail updates
- `GET /api/events` - Server-Sent Events for real-time notifications
### Agent (`cmd/agent/`)
@@ -138,8 +148,9 @@ Agents inactive for 6+ months:
### Views (`views/`)
- **Templ Templates**: Type-safe HTML templating
- **HTMX Integration**: Smooth, instant updates without page refreshes
- **Server-Sent Events**: Real-time push notifications from server
- **Responsive Design**: Works on desktop, tablet, and mobile
- **Auto-Refresh**: Dashboard refreshes every 5 seconds
- **Color-coded Status**: Visual indicators for system health
## Project Structure
@@ -148,28 +159,28 @@ Agents inactive for 6+ months:
nerd-monitor/
├── cmd/
│ ├── agent/
│ │ └── main.go # Agent executable
│ │ └── main.go # Agent executable with slog logging
│ └── server/
│ └── main.go # Server executable
│ └── main.go # Server executable with slog logging
├── internal/
│ ├── api/
│ │ └── api.go # API handlers
│ │ └── api.go # API handlers + real-time broadcaster
│ ├── auth/
│ │ ├── middleware.go # Auth middleware
│ │ ├── middleware.go # Auth middleware with detailed logging
│ │ └── errors.go # Error definitions
│ ├── stats/
│ │ └── stats.go # System stats collection
│ ├── store/
│ │ └── store.go # In-memory agent storage
│ │ └── store.go # In-memory agent storage with logging
│ └── ui/
│ └── handlers.go # Dashboard handlers
│ └── handlers.go # Dashboard handlers + HTMX endpoints
├── views/
│ ├── layout.templ # Base layout template
│ ├── dashboard.templ # Dashboard page
│ ├── agent_detail.templ # Agent detail page
│ ├── layout.templ # Base layout with HTMX + SSE
│ ├── dashboard.templ # Dashboard with real-time updates
│ ├── agent_detail.templ # Agent detail with live stats
│ ├── login.templ # Login page
│ ├── components.templ # Reusable components
│ └── generate.go # Templ code generation
│ └── *_templ.go # Generated Templ code
├── Makefile # Build system
├── go.mod / go.sum # Go dependencies
├── AGENTS.md # Agent guidelines
@@ -231,9 +242,10 @@ Options:
- **Agent Memory**: ~8-10 MB (depending on platform)
- **Server Memory**: Scales with connected agents (~1 MB per 1000 agents)
- **Network**: Minimal bandwidth (~1 KB per report)
- **Dashboard Refresh**: 5 seconds (configurable)
- **Network**: Minimal bandwidth (~1 KB per report + SSE connections)
- **Real-time Updates**: Instant UI updates when agents report stats
- **Agent Reporting**: 15 seconds (configurable)
- **SSE Connections**: Lightweight persistent connections for real-time notifications
## Building for Specific Platforms
@@ -261,6 +273,8 @@ make build-all
- `github.com/go-chi/chi/v5` - HTTP router
- `github.com/a-h/templ` - HTML templating
- `github.com/shirou/gopsutil/v3` - System statistics
- **HTMX** (CDN) - Frontend interactivity and real-time updates
- **Server-Sent Events** (Go stdlib) - Real-time push notifications
### Go Standard Library
@@ -270,6 +284,7 @@ make build-all
- `time` - Timestamps and durations
- `os` - Hostname, signals, I/O
- `crypto/rand` - Token generation
- `log/slog` - Structured logging throughout application
## Troubleshooting
@@ -279,6 +294,15 @@ make build-all
2. Check agent logs for connection errors
3. Ensure correct server address and port
4. Check firewall rules allow outbound connections
5. Check server logs for SSE broadcasting activity
### Stats not updating in real-time
1. Verify browser supports Server-Sent Events (SSE)
2. Check browser developer tools for JavaScript errors
3. Ensure `/api/events` endpoint is accessible (requires authentication)
4. Check server logs for SSE connection/disconnection messages
5. Verify HTMX library loaded correctly
### Agent shows "Offline" immediately
@@ -286,6 +310,7 @@ make build-all
2. Verify server address is reachable: `ping 10.0.20.80`
3. Check if server is listening on the correct port
4. Review server logs for errors
5. Note: Agents are marked offline if no report received in 15 seconds
### Can't login to dashboard
@@ -293,6 +318,7 @@ make build-all
2. Try the default: `admin` / `admin`
3. If changed, restart server with original credentials to reconfigure
4. Check browser cookies are enabled
5. Clear browser cache if SSE connections seem stuck
### Windows agent window appears then closes
@@ -312,6 +338,18 @@ make templ
go run github.com/a-h/templ/cmd/templ@latest generate
```
### Viewing Logs
The application uses structured logging with `slog`. Set log level with:
```bash
# Debug logging (shows all activity)
export GOLOGLEVEL=debug
# Info logging (default, shows important events)
export GOLOGLEVEL=info
```
### Running Tests
```bash
@@ -324,20 +362,42 @@ go test ./...
gofmt -w ./cmd ./internal ./views
```
### Testing Real-time Features
To test SSE and HTMX functionality:
```bash
# Start server
./bin/nerd-monitor-server
# In another terminal, send test stats
curl -X POST "http://localhost:8080/api/report?id=test-agent" \
-H "Content-Type: application/json" \
-d '{"hostname": "test", "cpuUsage": 25.5, "ramUsage": 1000000, "ramTotal": 8000000, "diskUsage": 50000000, "diskTotal": 100000000}'
```
## Contributing
Contributions are welcome! Please follow the code style guidelines in `AGENTS.md`.
## Recent Updates
-**Real-time Updates**: Instant UI updates via Server-Sent Events and HTMX
-**Structured Logging**: Comprehensive slog-based logging throughout
-**Enhanced Monitoring**: Better debugging and operational visibility
## Future Enhancements
- [ ] Database persistence (SQLite/PostgreSQL)
- [ ] Alerting system (email/Slack notifications)
- [ ] Historical data / graphing
- [ ] Historical data / graphing with time-series storage
- [ ] Agent grouping and tagging
- [ ] Custom metric collection
- [ ] TLS/HTTPS support
- [ ] Multi-tenancy
- [ ] API documentation (Swagger)
- [ ] API documentation (Swagger/OpenAPI)
- [ ] Webhook integrations
- [ ] Mobile app companion
## License

View File

@@ -2,7 +2,7 @@
## Overview
This project uses **Gitea Actions** to automatically build and release binaries and Docker images when you push to the `master` branch or create a new tag.
This project uses **Gitea Actions** to automatically build and release cross-platform binaries when you push to the `master` branch or create a new tag.
## Automatic Release Pipeline
@@ -11,13 +11,12 @@ This project uses **Gitea Actions** to automatically build and release binaries
1. **Master Branch Push**: When you push to `master`, the workflow:
- Builds all platform binaries (Linux/macOS/Windows, amd64/arm64)
- Generates checksums (SHA256)
- Uploads artifacts for 30 days
- Builds Docker images (server & agent)
- Binaries are available as build artifacts for 30 days
2. **Tag Creation**: When you create a tag (e.g., `v1.0.0`), the workflow:
- Does all of the above
- Creates a GitHub Release
- Uploads all binaries and Docker images to the release
- Creates a Gitea Release
- Uploads all binaries and checksums to the release (permanent storage)
### Supported Platforms
@@ -26,56 +25,81 @@ Binaries are built for:
- **macOS**: amd64 (Intel), arm64 (Apple Silicon)
- **Windows**: amd64
Docker images are built for Linux containers.
### What Gets Built and Released
When you push a tag, the workflow automatically creates:
**Binaries** (10 files total):
- `nerd-monitor-server-linux-amd64` - Server for Linux x86_64
- `nerd-monitor-server-linux-arm64` - Server for Linux ARM64 (Raspberry Pi, etc.)
- `nerd-monitor-server-darwin-amd64` - Server for macOS Intel
- `nerd-monitor-server-darwin-arm64` - Server for macOS Apple Silicon
- `nerd-monitor-server-windows-amd64.exe` - Server for Windows
- `nerd-monitor-agent-linux-amd64` - Agent for Linux x86_64
- `nerd-monitor-agent-linux-arm64` - Agent for Linux ARM64
- `nerd-monitor-agent-darwin-amd64` - Agent for macOS Intel
- `nerd-monitor-agent-darwin-arm64` - Agent for macOS Apple Silicon
- `nerd-monitor-agent-windows-amd64.exe` - Agent for Windows
**Checksums**:
- `SHA256SUMS` - SHA256 checksums for all binaries (for verification)
## Workflow Configuration
The Gitea Actions workflow is defined in `.gitea/workflows/release.yml`
### Trigger Events
- Push to `master` branch
- Push of git tags (e.g., `v1.0.0`)
- Push to `master` or `main` branch (builds only, no release)
- Push of git tags (e.g., `v1.0.0`) - triggers full release with uploads
### Jobs
- `build`: Compiles all platform binaries and generates checksums
- `docker-build`: Builds Docker images for server and agent
### Creating a Release
## Creating a Release
#### Step 1: Create and Push a Tag
### Option 1: Automatic Release (Recommended)
1. Create a new tag:
```bash
# Create an annotated tag
git tag -a v1.0.0 -m "Release version 1.0.0"
# Push the tag to Gitea
git push origin v1.0.0
```
2. Gitea Actions will automatically:
- Build all binaries
- Create a release in Gitea
- Upload all artifacts
#### Step 2: Monitor the Workflow
3. View the release in Gitea: `Releases` tab on your repository
1. Go to your repository on Gitea
2. Click the **Actions** tab
3. You'll see the workflow running:
- `build` job: Compiles all binaries (5-10 minutes)
- `docker-build` job: Builds Docker images (5-10 minutes)
### Option 2: Manual Release Upload
#### Step 3: Verify the Release
If you need to manually upload binaries to Gitea:
Once the workflow completes:
```bash
# Set your Gitea token (create one in Gitea Settings → Applications → Generate Token)
export GITEA_TOKEN=your_token_here
1. Go to the **Releases** tab
2. You'll see a new release with:
- All platform binaries (Linux, macOS, Windows)
- SHA256SUMS file with checksums
- Docker image files (.tar)
# Build all binaries
make build-all
### What Gets Built and Released
# Upload to release
./scripts/upload-release.sh v1.0.0
```
When you push a tag, the workflow automatically:
Environment variables (optional):
- `GITEA_URL`: Gitea server URL (default: `https://git.nerdnest.dev`)
- `REPO_OWNER`: Repository owner (default: `ducky`)
- `REPO_NAME`: Repository name (default: `nerd-monitor`)
**Binaries** (10 files total):
- `nerd-monitor-server-linux-amd64` - Server for Linux x86_64
- `nerd-monitor-server-linux-arm64` - Server for Linux ARM64 (Raspberry Pi, etc.)
- `nerd-monitor-server-darwin-amd64` - Server for macOS Intel
- `nerd-monitor-server-darwin-arm64` - Server for macOS Apple Silicon
- `nerd-monitor-server-windows-amd64.exe` - Server for Windows
- `nerd-monitor-agent-linux-amd64` - Agent for Linux x86_64
- `nerd-monitor-agent-linux-arm64` - Agent for Linux ARM64
- `nerd-monitor-agent-darwin-amd64` - Agent for macOS Intel
- `nerd-monitor-agent-darwin-arm64` - Agent for macOS Apple Silicon
- `nerd-monitor-agent-windows-amd64.exe` - Agent for Windows
**Checksums**:
- `SHA256SUMS` - SHA256 checksums for all binaries (for verification)
## Local Building
@@ -94,51 +118,86 @@ make clean
Binaries are created in the `bin/` directory.
## Docker Images
## Docker Images (Manual Build)
Two Docker images are built:
Docker images are available but not built automatically by CI/CD. To build them manually:
### Server Image
```bash
docker pull nerd-monitor-server:latest
docker run -p 8080:8080 nerd-monitor-server
# Build server image
docker build -t nerd-monitor-server:latest -f Dockerfile.server .
# Build agent image
docker build -t nerd-monitor-agent:latest -f Dockerfile.agent .
# Or use Docker Compose
docker-compose build
# Or use Docker Compose to run both
docker-compose up
```
### Agent Image
```bash
docker pull nerd-monitor-agent:latest
docker run nerd-monitor-agent --server your-server:8080
```
See `DOCKER_COMPOSE.md` and `QUICKSTART.md` for detailed instructions.
## Gitea Configuration
### Enable Gitea Actions
### Prerequisites
1. SSH into your Gitea server
2. Edit `app.ini`:
```ini
Before the CI/CD pipeline can create releases automatically, you need to:
1. **Ensure Gitea Actions is enabled** on your Gitea server
2. **Create a Gitea API Token** with release permissions
3. **Add the token as an Actions secret** in your repository
### Setup Instructions
#### 1. Enable Gitea Actions (Server Admin)
SSH into your Gitea server and verify Actions is enabled:
```bash
# Edit the Gitea configuration
sudo vi /etc/gitea/app.ini
# Add or verify:
[actions]
ENABLED = true
```
3. Restart Gitea:
Then restart Gitea:
```bash
systemctl restart gitea
sudo systemctl restart gitea
```
### Create an API Token (for manual uploads)
#### 2. Create an API Token
1. Go to Settings → Applications
2. Click "Generate New Token"
3. Name it (e.g., "Release Upload")
4. Give it `repo` permissions
5. Copy the token
1. Log in to Gitea with your user account
2. Go to **Settings****Applications**
3. Click **Generate New Token**
4. Fill in:
- **Token Name**: `release-automation` (or any descriptive name)
- **Scopes**: Select `repo` (full repository access)
5. Click **Generate Token**
6. **Copy the token** (you won't be able to see it again)
Use it with the upload script:
```bash
./scripts/upload-release.sh v1.0.0 <your_token>
```
#### 3. Add Token as Repository Secret
## Release Files
1. Go to your repository on Gitea
2. Navigate to **Settings****Secrets**
3. Click **Add Secret**
4. Fill in:
- **Secret Name**: `GITEA_TOKEN`
- **Secret Value**: Paste the token you copied
5. Click **Save**
Now the workflow will be able to create releases automatically!
### Workflow Configuration
The Gitea Actions workflow is defined in `.gitea/workflows/release.yml`
### Trigger Events
- Push to `master` or `main` branch (builds only, no release)
- Push of git tags (e.g., `v1.0.0`) - triggers full release with uploads
Each release includes:
@@ -158,35 +217,93 @@ nerd-monitor-agent-windows-amd64.exe
SHA256SUMS (checksums for all binaries)
```
## Coolify Integration
## Deployment Options
If you want to use Coolify for deployment:
### Option 1: Use Pre-Built Binaries (Recommended)
Download the native binaries from the Releases tab and run directly:
- Smallest footprint
- No Docker required
- Easiest to deploy to existing machines
1. **For Server Deployment**:
- Use `Dockerfile.server` as the build context
- Coolify will auto-build on `master` branch pushes
- Deploy the server container to Coolify
### Option 2: Use Docker (Manual Build)
Build Docker images manually and deploy:
```bash
docker build -t nerd-monitor-server:latest -f Dockerfile.server .
docker run -p 8080:8080 nerd-monitor-server:latest
```
2. **For Agent Deployment**:
- Use `Dockerfile.agent` as the build context
- Deploy the agent container to machines that need monitoring
See `docker-compose.yml` for running both server and agent together.
### Option 3: Use Docker Compose
For quick development/testing with both server and agent:
```bash
docker-compose up
```
See `DOCKER_COMPOSE.md` and `QUICKSTART.md` for details.
## Troubleshooting
### Actions not running
- Ensure Gitea Actions is enabled on your server
- Check that your runner is available (`Settings → Actions`)
- Review action logs in the `Actions` tab
### Workflow Not Triggering
### Release not created
- Ensure the tag format matches semantic versioning (v1.0.0)
- Check workflow logs for build errors
- Verify Go 1.24.4 is available in the runner environment
**Problem**: I pushed a tag but the workflow didn't start
### Docker images not building
- Ensure Docker/Buildx is available in the runner
- Check the Dockerfile syntax
- Review build logs in the Actions tab
**Solutions**:
1. Check that the tag is pushed: `git push origin v1.0.0`
2. Verify Gitea Actions is enabled: Settings → Actions → Status should show "Enabled"
3. Check Actions tab for any error messages
4. Ensure your runner is online: Settings → Runners
### Token Authentication Failed
**Problem**: `"message":"Unauthorized"` or token-related errors in the logs
**Solutions**:
1. Verify the `GITEA_TOKEN` secret is set correctly: Repository → Settings → Secrets
2. Ensure the token has `repo` scope permissions
3. Token should not be expired - regenerate if needed
4. Double-check there are no extra spaces in the token
### Build Fails with "Go not found"
**Problem**: Workflow fails when trying to build, says Go is not available
**Solutions**:
1. This is usually a temporary issue - runner environment might not have been fully initialized
2. Retry the workflow: Go to Actions tab → Click the failed workflow → Click "Re-run jobs"
3. Check if the runner has enough disk space: `df -h` on the runner machine
### Binaries Not Uploaded to Release
**Problem**: Workflow completes but binaries don't appear in the release
**Solutions**:
1. Check the workflow logs: Actions tab → Click the workflow → View logs
2. Look for "Uploading" messages and any error messages
3. Verify the release was created: Go to Releases tab
4. Check that `GITEA_TOKEN` secret is still valid (tokens can expire)
### How to Check Workflow Logs
1. Go to your Gitea repository
2. Click the **Actions** tab
3. Click on the workflow run (should show the tag name)
4. Click on the `build` job
5. Scroll through the log to find error messages
6. Look for red `❌` marks indicating failures
### Manual Trigger for Testing
If you want to test the workflow without creating a full release:
```bash
# Push to main/master branch (triggers build only, no release)
git push origin main
# Then push a tag when you're ready (triggers full release)
git tag -a v1.0.0 -m "Release"
git push origin v1.0.0
```
## Additional Resources
@@ -194,3 +311,4 @@ If you want to use Coolify for deployment:
- [Project README](./README.md)
- [Quick Start Guide](./QUICKSTART.md)
- [Agent Guidelines](./AGENTS.md)
- [Docker Compose Guide](./DOCKER_COMPOSE.md)

View File

@@ -5,7 +5,7 @@ import (
"encoding/json"
"flag"
"fmt"
"log"
"log/slog"
"net/http"
"net/url"
"os"
@@ -25,8 +25,12 @@ func main() {
)
flag.Parse()
// Set up verbose logging
slog.SetLogLoggerLevel(slog.LevelDebug)
if *server == "" {
log.Fatal("--server flag is required")
slog.Error("Server flag is required")
os.Exit(1)
}
// Normalize server URL (add http:// if missing)
@@ -38,12 +42,13 @@ func main() {
var err error
id, err = generateAgentID()
if err != nil {
log.Fatalf("Failed to generate agent ID: %v", err)
slog.Error("Failed to generate agent ID", "error", err)
os.Exit(1)
}
}
log.Printf("Starting agent with ID: %s\n", id)
log.Printf("Reporting to: %s every %v\n", *server, *interval)
slog.Info("Starting agent", "id", id)
slog.Info("Reporting configuration", "server", *server, "interval", *interval)
// Initialize stats collector
collector := stats.NewCollector()
@@ -64,7 +69,7 @@ func main() {
case <-ticker.C:
reportStats(id, *server, collector)
case <-sigChan:
log.Println("Agent shutting down gracefully...")
slog.Info("Agent shutting down gracefully")
os.Exit(0)
}
}
@@ -79,37 +84,39 @@ func reportStats(agentID, serverURL string, collector *stats.Collector) {
stat, err := collector.Collect(hostname)
if err != nil {
log.Printf("Error collecting stats: %v", err)
slog.Error("Error collecting stats", "agentID", agentID, "hostname", hostname, "error", err)
return
}
// Marshal to JSON
body, err := json.Marshal(stat)
if err != nil {
log.Printf("Error marshaling stats: %v", err)
slog.Error("Error marshaling stats", "agentID", agentID, "error", err)
return
}
// Send to server
reportURL := fmt.Sprintf("%s/api/report?id=%s", serverURL, agentID)
slog.Debug("Sending stats report", "agentID", agentID, "url", reportURL, "bodySize", len(body))
resp, err := http.Post(reportURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Error reporting stats: %v", err)
slog.Error("Error reporting stats", "agentID", agentID, "url", reportURL, "error", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("Server returned status %d", resp.StatusCode)
slog.Error("Server returned non-OK status", "agentID", agentID, "statusCode", resp.StatusCode, "url", reportURL)
return
}
log.Printf("Stats reported: CPU %.1f%% | RAM %s / %s | Disk %s / %s",
stat.CPUUsage,
formatBytes(stat.RAMUsage),
formatBytes(stat.RAMTotal),
formatBytes(stat.DiskUsage),
formatBytes(stat.DiskTotal),
slog.Debug("Stats reported successfully",
"agentID", agentID,
"cpu", stat.CPUUsage,
"ramUsage", formatBytes(stat.RAMUsage),
"ramTotal", formatBytes(stat.RAMTotal),
"diskUsage", formatBytes(stat.DiskUsage),
"diskTotal", formatBytes(stat.DiskTotal),
)
}

View File

@@ -2,7 +2,7 @@ package main
import (
"flag"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
@@ -24,6 +24,9 @@ func main() {
)
flag.Parse()
// Set up verbose logging
slog.SetLogLoggerLevel(slog.LevelDebug)
// Initialize dependencies
s := store.New()
authMgr := auth.New(*username, *password)
@@ -40,11 +43,20 @@ func main() {
r.Get("/login", uiHandler.Login)
r.Post("/login", uiHandler.Login)
// Public routes (no auth required)
r.Post("/api/report", apiHandler.ReportStats)
r.Get("/api/agents", apiHandler.ListAgents)
r.Get("/api/agents/{id}", apiHandler.GetAgent)
// Protected routes (auth required)
r.Group(func(protectedRoutes chi.Router) {
protectedRoutes.Use(authMgr.Middleware)
protectedRoutes.Get("/", uiHandler.Dashboard)
protectedRoutes.Get("/agents/{id}", uiHandler.AgentDetail)
protectedRoutes.Get("/api/dashboard/table", uiHandler.GetDashboardTable)
protectedRoutes.Get("/api/agents/{id}/stats", uiHandler.GetAgentStats)
protectedRoutes.Get("/api/events", uiHandler.Events)
protectedRoutes.Post("/agents/{id}/hostname", uiHandler.UpdateAgentHostname)
protectedRoutes.Post("/logout", uiHandler.Logout)
protectedRoutes.Post("/api/agents/remove-stale", uiHandler.RemoveStaleAgents)
protectedRoutes.Post("/api/agents/{id}/delete", uiHandler.DeleteAgent)
@@ -62,14 +74,15 @@ func main() {
go func() {
<-sigChan
log.Println("Shutting down server...")
slog.Info("Shutting down server...")
server.Close()
os.Exit(0)
}()
log.Printf("Starting server on http://%s:%s\n", *addr, *port)
log.Printf("Login with %s / %s\n", *username, *password)
slog.Info("Starting server", "addr", *addr+":"+*port, "url", "http://"+*addr+":"+*port)
slog.Info("Login credentials", "username", *username, "password", *password)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
slog.Error("Server error", "error", err)
os.Exit(1)
}
}

View File

@@ -1,5 +1,3 @@
version: '3.8'
# ============================================================================
# Nerd Monitor Docker Compose Configuration
# ============================================================================
@@ -40,7 +38,6 @@ services:
context: .
dockerfile: Dockerfile.server
container_name: nerd-monitor-server
image: nerd-monitor-server:latest
ports:
- "8080:8080"
environment:
@@ -82,7 +79,6 @@ services:
build:
context: .
dockerfile: Dockerfile.agent
image: nerd-monitor-agent:latest
environment:
# Agent configuration
SERVER: "server:8080" # Connect to the server service

View File

@@ -2,11 +2,59 @@ package api
import (
"encoding/json"
"log/slog"
"net/http"
"sync"
"github.com/go-chi/chi/v5"
"nerd-monitor/internal/stats"
"nerd-monitor/internal/store"
"net/http"
)
// Broadcaster manages Server-Sent Events clients.
type Broadcaster struct {
clients map[chan string]bool
mu sync.RWMutex
}
var apiBroadcaster = &Broadcaster{
clients: make(map[chan string]bool),
}
// Broadcast sends a message to all connected SSE clients.
func (b *Broadcaster) Broadcast(message string) {
b.mu.RLock()
defer b.mu.RUnlock()
for clientChan := range b.clients {
select {
case clientChan <- message:
default:
// Client channel is full, skip
}
}
}
// AddClient adds a new SSE client.
func (b *Broadcaster) AddClient(clientChan chan string) {
b.mu.Lock()
defer b.mu.Unlock()
b.clients[clientChan] = true
}
// RemoveClient removes an SSE client.
func (b *Broadcaster) RemoveClient(clientChan chan string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.clients, clientChan)
close(clientChan)
}
// GetAPIBroadcaster returns the API broadcaster instance.
func GetAPIBroadcaster() *Broadcaster {
return apiBroadcaster
}
// Handler manages HTTP requests.
type Handler struct {
store *store.Store
@@ -19,57 +67,121 @@ func New(s *store.Store) *Handler {
// ReportStats handles agent stats reports.
func (h *Handler) ReportStats(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming stats report request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
agentID := r.URL.Query().Get("id")
if agentID == "" {
slog.Warn("Missing agent ID in stats report", "remoteAddr", r.RemoteAddr)
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
var stat stats.Stats
if err := json.NewDecoder(r.Body).Decode(&stat); err != nil {
slog.Error("Invalid request body in stats report", "agentID", agentID, "error", err)
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
slog.Debug("Updating agent stats", "agentID", agentID, "hostname", stat.Hostname, "cpu", stat.CPUUsage)
h.store.UpdateAgent(agentID, &stat)
// Broadcast update to all connected SSE clients
message := "event: stats-update\ndata: {\"type\": \"stats-update\", \"agentId\": \"" + agentID + "\"}\n\n"
slog.Info("Broadcasting stats update", "agentID", agentID)
apiBroadcaster.Broadcast(message)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
slog.Debug("Stats report processed successfully", "agentID", agentID)
}
// GetAgent returns stats for a single agent.
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming get agent request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
agentID := r.URL.Query().Get("id")
if agentID == "" {
slog.Warn("Missing agent ID in get agent request", "remoteAddr", r.RemoteAddr)
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
slog.Debug("Retrieving agent stats", "agentID", agentID)
agent := h.store.GetAgent(agentID)
if agent == nil {
slog.Warn("Agent not found", "agentID", agentID)
http.Error(w, "agent not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(agent)
slog.Debug("Agent stats retrieved successfully", "agentID", agentID)
}
// ListAgents returns all agents.
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming list agents request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
slog.Debug("Retrieving all agents")
agents := h.store.GetAllAgents()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(agents)
slog.Debug("All agents listed successfully", "count", len(agents))
}
// DeleteAgent removes an agent.
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming delete agent request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
agentID := r.URL.Query().Get("id")
if agentID == "" {
slog.Warn("Missing agent ID in delete agent request", "remoteAddr", r.RemoteAddr)
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
slog.Info("Deleting agent", "agentID", agentID)
h.store.DeleteAgent(agentID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
slog.Info("Agent deleted successfully", "agentID", agentID)
}
// UpdateHostname updates the hostname for an agent.
func (h *Handler) UpdateHostname(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming update hostname request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
if r.Method != http.MethodPost {
slog.Warn("Invalid method for update hostname", "method", r.Method)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
agentID := chi.URLParam(r, "id")
if agentID == "" {
slog.Warn("Missing agent ID in update hostname request", "remoteAddr", r.RemoteAddr)
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
hostname := r.FormValue("hostname")
if hostname == "" {
slog.Warn("Missing hostname in update request", "agentID", agentID)
http.Error(w, "missing hostname", http.StatusBadRequest)
return
}
slog.Info("Updating agent hostname", "agentID", agentID, "newHostname", hostname)
err := h.store.UpdateHostname(agentID, hostname)
if err != nil {
slog.Warn("Failed to update hostname - agent not found", "agentID", agentID, "error", err)
http.Error(w, "agent not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "updated"})
slog.Info("Agent hostname updated successfully", "agentID", agentID, "hostname", hostname)
}

View File

@@ -3,6 +3,7 @@ package auth
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"sync"
"time"
@@ -36,11 +37,13 @@ func New(username, password string) *Manager {
// Login validates credentials and creates a session.
func (m *Manager) Login(username, password string) (string, error) {
if username != m.username || password != m.password {
slog.Debug("Login failed - invalid credentials", "username", username)
return "", ErrInvalidCredentials
}
token, err := generateToken()
if err != nil {
slog.Error("Failed to generate session token", "error", err)
return "", err
}
@@ -52,6 +55,7 @@ func (m *Manager) Login(username, password string) (string, error) {
ExpiresAt: time.Now().Add(m.expiryDur),
}
slog.Debug("Login successful, session created", "username", username, "token", token[:8]+"...")
return token, nil
}
@@ -62,10 +66,17 @@ func (m *Manager) Validate(token string) bool {
session, ok := m.sessions[token]
if !ok {
slog.Debug("Session validation failed - token not found", "token", token[:8]+"...")
return false
}
return session.ExpiresAt.After(time.Now())
if !session.ExpiresAt.After(time.Now()) {
slog.Debug("Session validation failed - token expired", "token", token[:8]+"...", "expiredAt", session.ExpiresAt)
return false
}
slog.Debug("Session validation successful", "token", token[:8]+"...")
return true
}
// Logout invalidates a session.
@@ -74,16 +85,24 @@ func (m *Manager) Logout(token string) {
defer m.mu.Unlock()
delete(m.sessions, token)
slog.Debug("Session logged out", "token", token[:8]+"...")
}
// Middleware returns a Chi middleware for authentication.
func (m *Manager) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_token")
if err != nil || !m.Validate(cookie.Value) {
if err != nil {
slog.Debug("Authentication failed - no session cookie", "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !m.Validate(cookie.Value) {
slog.Debug("Authentication failed - invalid session", "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
slog.Debug("Authentication successful", "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
next.ServeHTTP(w, r)
})
}

View File

@@ -1,12 +1,19 @@
package store
import (
"errors"
"log/slog"
"sync"
"time"
"nerd-monitor/internal/stats"
)
var (
// ErrAgentNotFound is returned when an agent is not found.
ErrAgentNotFound = errors.New("agent not found")
)
// AgentStats represents the latest stats for an agent.
type AgentStats struct {
ID string
@@ -47,6 +54,7 @@ func (s *Store) UpdateAgent(id string, stat *stats.Stats) {
DiskTotal: stat.DiskTotal,
LastSeen: time.Now(),
}
slog.Debug("Agent stats updated", "agentID", id, "hostname", stat.Hostname, "cpu", stat.CPUUsage)
}
// GetAgent retrieves agent stats by ID.
@@ -54,7 +62,13 @@ func (s *Store) GetAgent(id string) *AgentStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.agents[id]
agent := s.agents[id]
if agent != nil {
slog.Debug("Agent retrieved", "agentID", id, "hostname", agent.Hostname)
} else {
slog.Debug("Agent not found", "agentID", id)
}
return agent
}
// GetAllAgents returns all agents.
@@ -66,6 +80,7 @@ func (s *Store) GetAllAgents() []*AgentStats {
for _, agent := range s.agents {
agents = append(agents, agent)
}
slog.Debug("All agents retrieved", "count", len(agents))
return agents
}
@@ -75,4 +90,21 @@ func (s *Store) DeleteAgent(id string) {
defer s.mu.Unlock()
delete(s.agents, id)
slog.Debug("Agent deleted", "agentID", id)
}
// UpdateHostname updates the hostname for an agent.
func (s *Store) UpdateHostname(id string, hostname string) error {
s.mu.Lock()
defer s.mu.Unlock()
agent, exists := s.agents[id]
if !exists {
slog.Debug("Agent not found for hostname update", "agentID", id)
return ErrAgentNotFound
}
agent.Hostname = hostname
slog.Debug("Agent hostname updated", "agentID", id, "hostname", hostname)
return nil
}

View File

@@ -2,9 +2,13 @@ package ui
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"nerd-monitor/internal/api"
"nerd-monitor/internal/auth"
"nerd-monitor/internal/store"
"nerd-monitor/views"
@@ -14,6 +18,10 @@ import (
type Handler struct {
store *store.Store
auth *auth.Manager
broadcaster interface {
AddClient(chan string)
RemoveClient(chan string)
}
}
// New creates a new UI handler.
@@ -21,35 +29,48 @@ func New(s *store.Store, a *auth.Manager) *Handler {
return &Handler{
store: s,
auth: a,
broadcaster: api.GetAPIBroadcaster(),
}
}
// Dashboard renders the dashboard page.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming dashboard request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
slog.Debug("Rendering dashboard", "agentCount", len(h.store.GetAllAgents()), "staleAgentCount", len(h.getStaleAgents()))
agents := h.store.GetAllAgents()
staleAgents := h.getStaleAgents()
component := views.Dashboard(agents, staleAgents)
component.Render(context.Background(), w)
slog.Debug("Dashboard rendered successfully")
}
// AgentDetail renders the agent detail page.
func (h *Handler) AgentDetail(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
slog.Debug("Incoming agent detail request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
agentID := chi.URLParam(r, "id")
slog.Debug("Retrieving agent detail", "agentID", agentID)
agent := h.store.GetAgent(agentID)
if agent == nil {
slog.Warn("Agent not found for detail view", "agentID", agentID)
http.NotFound(w, r)
return
}
component := views.AgentDetail(agent)
component.Render(context.Background(), w)
slog.Debug("Agent detail rendered successfully", "agentID", agentID)
}
// Login renders the login page.
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming login request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
if r.Method == http.MethodGet {
slog.Debug("Rendering login page")
component := views.LoginPage("")
component.Render(context.Background(), w)
return
@@ -59,13 +80,16 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
slog.Debug("Attempting login", "username", username, "remoteAddr", r.RemoteAddr)
token, err := h.auth.Login(username, password)
if err != nil {
slog.Warn("Login failed - invalid credentials", "username", username, "remoteAddr", r.RemoteAddr, "error", err)
component := views.LoginPage("Invalid credentials")
component.Render(context.Background(), w)
return
}
slog.Info("Login successful", "username", username, "remoteAddr", r.RemoteAddr)
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: token,
@@ -79,19 +103,26 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return
}
slog.Warn("Invalid method for login", "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// Logout handles logout.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming logout request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
if r.Method != http.MethodPost {
slog.Warn("Invalid method for logout", "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cookie, err := r.Cookie("session_token")
if err == nil {
slog.Info("Logging out user", "remoteAddr", r.RemoteAddr)
h.auth.Logout(cookie.Value)
} else {
slog.Debug("Logout attempted without valid session", "remoteAddr", r.RemoteAddr)
}
http.SetCookie(w, &http.Cookie{
@@ -107,32 +138,73 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
// RemoveStaleAgents handles bulk removal of stale agents.
func (h *Handler) RemoveStaleAgents(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming remove stale agents request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
if r.Method != http.MethodPost {
slog.Warn("Invalid method for remove stale agents", "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
agentIDs := r.Form["agent_ids"]
slog.Info("Removing stale agents", "count", len(agentIDs), "agentIDs", agentIDs)
for _, id := range agentIDs {
h.store.DeleteAgent(id)
}
slog.Info("Stale agents removed successfully", "count", len(agentIDs))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// DeleteAgent handles single agent deletion.
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming delete agent request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
if r.Method != http.MethodPost {
slog.Warn("Invalid method for delete agent", "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
agentID := r.PathValue("id")
agentID := chi.URLParam(r, "id")
slog.Info("Deleting agent", "agentID", agentID)
h.store.DeleteAgent(agentID)
slog.Info("Agent deleted successfully", "agentID", agentID)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// UpdateAgentHostname handles hostname updates for agents.
func (h *Handler) UpdateAgentHostname(w http.ResponseWriter, r *http.Request) {
slog.Debug("Incoming update agent hostname request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
if r.Method != http.MethodPost {
slog.Warn("Invalid method for update agent hostname", "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
agentID := chi.URLParam(r, "id")
hostname := r.FormValue("hostname")
if hostname == "" {
slog.Warn("Empty hostname provided for update", "agentID", agentID)
http.Error(w, "Hostname cannot be empty", http.StatusBadRequest)
return
}
slog.Info("Updating agent hostname", "agentID", agentID, "newHostname", hostname)
err := h.store.UpdateHostname(agentID, hostname)
if err != nil {
slog.Warn("Failed to update hostname - agent not found", "agentID", agentID, "error", err)
http.NotFound(w, r)
return
}
slog.Info("Agent hostname updated successfully", "agentID", agentID, "hostname", hostname)
http.Redirect(w, r, "/agents/"+agentID, http.StatusSeeOther)
}
// getStaleAgents returns agents that haven't reported in 6 months.
func (h *Handler) getStaleAgents() []*store.AgentStats {
const staleThreshold = 6 * 30 * 24 * time.Hour
@@ -147,3 +219,177 @@ func (h *Handler) getStaleAgents() []*store.AgentStats {
return stale
}
// GetDashboardTable returns HTML fragment for the agent table.
func (h *Handler) GetDashboardTable(w http.ResponseWriter, r *http.Request) {
slog.Debug("HTMX dashboard table request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
agents := h.store.GetAllAgents()
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "no-cache")
if len(agents) == 0 {
w.Write([]byte(`<div class="alert alert-info">
<strong> No agents connected</strong>
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
Start an agent to see its statistics appear here.
</p>
</div>`))
return
}
w.Write([]byte(`<div class="card">
<div class="card-title">Active Agents</div>
<table>
<thead>
<tr>
<th>Hostname</th>
<th>CPU Usage</th>
<th>Memory</th>
<th>Disk</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>`))
for _, agent := range agents {
// Determine online status (agent is online if reported within last 15 seconds)
isOnline := time.Since(agent.LastSeen) < 15*time.Second
statusClass := "status-red"
statusText := "Offline"
if isOnline {
statusClass = "status-green"
statusText = "Online"
}
row := fmt.Sprintf(`<tr>
<td>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="status-badge %s" style="margin: 0;">%s</span>
<a href="/agents/%s" style="color: #3b82f6; text-decoration: none;">%s</a>
</div>
</td>
<td>%.1f%%</td>
<td>%s / %s</td>
<td>%s / %s</td>
<td class="timestamp">%s</td>
</tr>`,
statusClass,
statusText,
agent.ID,
agent.Hostname,
agent.CPUUsage,
formatBytes(agent.RAMUsage),
formatBytes(agent.RAMTotal),
formatBytes(agent.DiskUsage),
formatBytes(agent.DiskTotal),
agent.LastSeen.Format("2006-01-02 15:04:05"))
w.Write([]byte(row))
}
w.Write([]byte(`</tbody></table></div>`))
}
// GetAgentStats returns HTML fragment for agent statistics.
func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) {
slog.Debug("HTMX agent stats request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
agentID := chi.URLParam(r, "id")
if agentID == "" {
slog.Warn("Missing agent ID in stats request", "remoteAddr", r.RemoteAddr)
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
agent := h.store.GetAgent(agentID)
if agent == nil {
slog.Warn("Agent not found for stats request", "agentID", agentID)
http.Error(w, "agent not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "no-cache")
// Return just the stats content that should be updated
statsHTML := fmt.Sprintf(`<div style="font-size: 2rem; margin: 1rem 0;">%.1f%%</div>
<div class="progress-bar">
<div class="progress-fill" style="width: %.1f%%"></div>
</div>
<div style="margin-top: 1rem; font-size: 0.875rem; color: #94a3b8;">
Last updated: %s
</div>`,
agent.CPUUsage,
agent.CPUUsage,
agent.LastSeen.Format("2006-01-02 15:04:05"))
w.Write([]byte(statsHTML))
}
// Events provides Server-Sent Events for real-time updates.
func (h *Handler) Events(w http.ResponseWriter, r *http.Request) {
slog.Info("SSE connection established", "remoteAddr", r.RemoteAddr)
// Set headers for Server-Sent Events
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Create a channel for this client
clientChan := make(chan string, 10)
h.broadcaster.AddClient(clientChan)
// Clean up when client disconnects
go func() {
<-r.Context().Done()
slog.Debug("SSE client disconnected", "remoteAddr", r.RemoteAddr)
h.broadcaster.RemoveClient(clientChan)
}()
// Send initial connection event
fmt.Fprintf(w, "event: connected\ndata: {}\n\n")
w.(http.Flusher).Flush()
// Send a test event after 2 seconds
go func() {
time.Sleep(2 * time.Second)
select {
case clientChan <- "event: test\ndata: {\"message\": \"SSE working\"}\n\n":
default:
}
}()
// Listen for broadcast messages
for {
select {
case <-r.Context().Done():
return
case message := <-clientChan:
slog.Debug("Sending SSE message to client", "remoteAddr", r.RemoteAddr, "message", message)
fmt.Fprintf(w, "%s\n", message)
w.(http.Flusher).Flush()
}
}
}
// formatBytes converts bytes to human-readable format.
func formatBytes(bytes uint64) string {
const (
kb = 1024
mb = kb * 1024
gb = mb * 1024
)
switch {
case bytes >= gb:
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(gb))
case bytes >= mb:
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
case bytes >= kb:
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
default:
return fmt.Sprintf("%d B", bytes)
}
}

View File

@@ -9,6 +9,10 @@ templ AgentDetail(agent *store.AgentStats) {
@BaseLayout(agent.Hostname, agentDetailContent(agent))
}
templ AgentDetailContent(agent *store.AgentStats) {
@agentDetailContent(agent)
}
templ agentDetailContent(agent *store.AgentStats) {
<a href="/" class="back-link">← Back to Dashboard</a>
@@ -17,21 +21,25 @@ templ agentDetailContent(agent *store.AgentStats) {
@AgentStatusBadge(agent.LastSeen)
</div>
<!-- CPU Usage Card -->
<div class="grid" style="grid-template-columns: 1fr;">
<div class="card">
<div class="card-title">CPU Usage</div>
<div id="cpu-stats" hx-get={ templ.SafeURL("/api/agents/" + agent.ID + "/stats") } hx-trigger="stats-update" hx-swap="innerHTML">
<div class="metric-value" style="font-size: 2rem; margin: 1rem 0;">
{ FormatPercent(agent.CPUUsage) }
</div>
<div class="progress-bar">
<div class={ "progress-fill", calcProgressClass(agent.CPUUsage/100) } style={ fmt.Sprintf("width: %.1f%%", agent.CPUUsage) }></div>
<div class="progress-fill" class={ "progress-fill", calcProgressClass(agent.CPUUsage/100) } style={ fmt.Sprintf("width: %.1f%%", agent.CPUUsage) }></div>
</div>
<div style="margin-top: 1rem; font-size: 0.875rem; color: #94a3b8;">
Last updated: { FormatTime(agent.LastSeen) }
</div>
</div>
</div>
</div>
<!-- Memory and Disk Usage Cards -->
<div class="grid" style="margin-top: 1.5rem;">
<div class="card">
<div class="card-title">Memory Usage</div>
@@ -43,24 +51,49 @@ templ agentDetailContent(agent *store.AgentStats) {
</div>
</div>
<!-- Agent Information and Settings -->
<div class="card" style="margin-top: 1.5rem;">
<div class="card-title">Agent Information</div>
<div class="metric-row">
<span class="metric-label">Agent ID</span>
<span class="metric-value" style="font-family: monospace; font-size: 0.875rem;">{ agent.ID }</span>
</div>
<div class="metric-row">
<span class="metric-label">Hostname</span>
<span class="metric-value">{ agent.Hostname }</span>
</div>
<div class="metric-row">
<span class="metric-label">Last Seen</span>
<span class="metric-value">{ agent.LastSeen.Format("2006-01-02 15:04:05") }</span>
</div>
</div>
<div class="btn-group" style="margin-top: 1.5rem;">
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/api/agents/%s/delete", agent.ID)) } style="margin: 0;" onsubmit="return confirm('Are you sure you want to remove this agent?');">
<!-- Hostname Settings -->
<div class="card" style="margin-top: 1.5rem;">
<div class="card-title">Settings</div>
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/agents/%s/hostname", agent.ID)) } style="display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<label for="hostname" style="font-size: 0.875rem; font-weight: 500; color: #cbd5e1;">
Hostname
</label>
<input
type="text"
id="hostname"
name="hostname"
value={ agent.Hostname }
required
style="padding: 0.5rem 0.75rem; background-color: #1e293b; border: 1px solid #475569; border-radius: 0.375rem; color: #e2e8f0; font-size: 0.875rem;"
/>
</div>
<button type="submit" class="btn btn-primary" style="align-self: flex-start;">
Update Hostname
</button>
</form>
</div>
<!-- Danger Zone -->
<div class="card" style="margin-top: 1.5rem; border-left: 3px solid #ef4444;">
<div class="card-title" style="color: #ef4444;">Danger Zone</div>
<p style="margin: 0 0 1rem 0; color: #94a3b8; font-size: 0.875rem;">
Deleting this agent will remove all its data and history.
</p>
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/api/agents/%s/delete", agent.ID)) } style="margin: 0;" onsubmit="return confirm('Are you sure you want to remove this agent? This action cannot be undone.');">
<button type="submit" class="btn btn-danger">Delete Agent</button>
</form>
</div>

View File

@@ -8,11 +8,16 @@ templ Dashboard(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
@BaseLayout("Dashboard", dashboardContent(agents, staleAgents))
}
templ DashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
@dashboardContent(agents, staleAgents)
}
templ dashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
<h2 style="margin-bottom: 1rem;">Agent Status Overview</h2>
@StaleAgentAlert(len(staleAgents), nil)
<div id="agent-table-container" hx-get="/api/dashboard/table" hx-trigger="stats-update" hx-swap="innerHTML">
if len(agents) == 0 {
<div class="alert alert-info">
<strong> No agents connected</strong>
@@ -41,6 +46,7 @@ templ dashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentSta
</table>
</div>
}
</div>
if len(staleAgents) > 0 {
<div class="card" style="margin-top: 2rem; border-color: #d97706;">
@@ -71,6 +77,7 @@ templ dashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentSta
</div>
</form>
</div>
}
<script>
function toggleAll(checkbox) {
const checkboxes = document.querySelectorAll('.agent-checkbox');
@@ -78,4 +85,3 @@ templ dashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentSta
}
</script>
}
}

View File

@@ -1,3 +0,0 @@
//go:generate templ generate
package views

View File

@@ -6,8 +6,31 @@ templ BaseLayout(title string, content templ.Component) {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="refresh" content="5" />
<title>{ title } - Nerd Monitor</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Connect to Server-Sent Events for real-time updates
const eventSource = new EventSource('/api/events');
eventSource.onmessage = function(event) {
console.log('SSE message received:', event.data);
};
eventSource.addEventListener('stats-update', function(event) {
console.log('Stats update event:', event.data);
// Trigger HTMX updates using HTMX's API
const agentTable = document.getElementById('agent-table-container');
const cpuStats = document.getElementById('cpu-stats');
if (agentTable) htmx.trigger(agentTable, 'stats-update');
if (cpuStats) htmx.trigger(cpuStats, 'stats-update');
});
eventSource.addEventListener('connected', function(event) {
console.log('SSE connected');
});
eventSource.onerror = function(event) {
console.log('SSE error:', event);
};
});
</script>
<style>
* {
margin: 0;