Add API token scopes and standalone host agent
Introduces granular permission scopes for API tokens (docker:report, docker:manage, host-agent:report, monitoring:read/write, settings:read/write) allowing tokens to be restricted to minimum required access. Legacy tokens default to full access until scopes are explicitly configured. Adds standalone host agent for monitoring Linux, macOS, and Windows servers outside Proxmox/Docker estates. New Servers workspace in UI displays uptime, OS metadata, and capacity metrics from enrolled agents. Includes comprehensive token management UI overhaul with scope presets, inline editing, and visual scope indicators.
This commit is contained in:
parent
e76ab5eec0
commit
5c54685f04
39 changed files with 3475 additions and 332 deletions
|
|
@ -50,6 +50,7 @@ Pulse is built by a solo developer in evenings and weekends. Your support helps:
|
|||
- **Interactive Backup Explorer**: Cross-highlighted bar chart + grid with quick time-range pivots (24h/7d/30d/custom) and contextual tooltips for the busiest jobs
|
||||
- Proxmox Mail Gateway analytics: mail volume, spam/virus trends, quarantine health, and cluster node status
|
||||
- Optional Docker container monitoring via lightweight agent
|
||||
- Standalone host agent for Linux, macOS, and Windows servers to capture uptime, OS metadata, and capacity metrics
|
||||
- Config export/import with encryption and authentication
|
||||
- Automatic stable updates with safe rollback (opt-in)
|
||||
- Dark/light themes, responsive design
|
||||
|
|
@ -110,7 +111,7 @@ helm install pulse oci://ghcr.io/rcourtman/pulse-chart \
|
|||
1. Open `http://<your-server>:7655`
|
||||
2. **Complete the mandatory security setup** (first-time only)
|
||||
3. Create your admin username and password
|
||||
4. Use **Settings → Security → API tokens** to mint dedicated tokens for automation (issue one token per integration so you can revoke credentials individually)
|
||||
4. Use **Settings → Security → API tokens** to mint dedicated tokens for automation. Assign scopes so each token only has the permissions it needs (e.g. `docker:report`, `host-agent:report`). Legacy tokens default to full access until you edit and save new scopes.
|
||||
|
||||
**Option B: Automated Setup (No UI)**
|
||||
For automated deployments, configure authentication via environment variables:
|
||||
|
|
@ -168,6 +169,10 @@ The script handles user creation, permissions, token generation, and registratio
|
|||
|
||||
Deploy the lightweight [Pulse Docker agent](docs/DOCKER_MONITORING.md) on any host running Docker to stream container status and resource data back to Pulse. Install the agent alongside your stack, point it at your Pulse URL and API token, and the **Docker** workspace lights up with host summaries, restart loop detection, per-container CPU/memory charts, and quick filters for stacks and unhealthy workloads.
|
||||
|
||||
### Monitor Standalone Servers (optional)
|
||||
|
||||
Install the [Pulse host agent](docs/HOST_AGENT.md) on Linux, macOS, or Windows machines that sit outside your Proxmox or Docker estate. Generate an API token scoped to `host-agent:report`, drop it into the install command, and the **Servers** workspace will populate with uptime, OS metadata, and capacity metrics.
|
||||
|
||||
## Docker
|
||||
|
||||
### Basic
|
||||
|
|
|
|||
147
cmd/pulse-host-agent/main.go
Normal file
147
cmd/pulse-host-agent/main.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type multiValue []string
|
||||
|
||||
func (m *multiValue) String() string {
|
||||
return strings.Join(*m, ",")
|
||||
}
|
||||
|
||||
func (m *multiValue) Set(value string) error {
|
||||
*m = append(*m, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
|
||||
cfg.Logger = &logger
|
||||
|
||||
agent, err := hostagent.New(cfg)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to initialise host agent")
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
logger.Info().
|
||||
Str("pulse_url", cfg.PulseURL).
|
||||
Str("agent_id", cfg.AgentID).
|
||||
Dur("interval", cfg.Interval).
|
||||
Msg("Starting Pulse host agent")
|
||||
|
||||
if err := agent.Run(ctx); err != nil && err != context.Canceled {
|
||||
logger.Fatal().Err(err).Msg("host agent terminated with error")
|
||||
}
|
||||
|
||||
logger.Info().Msg("Host agent stopped")
|
||||
}
|
||||
|
||||
func loadConfig() hostagent.Config {
|
||||
envURL := strings.TrimSpace(os.Getenv("PULSE_URL"))
|
||||
envToken := strings.TrimSpace(os.Getenv("PULSE_TOKEN"))
|
||||
envInterval := strings.TrimSpace(os.Getenv("PULSE_INTERVAL"))
|
||||
envHostname := strings.TrimSpace(os.Getenv("PULSE_HOSTNAME"))
|
||||
envAgentID := strings.TrimSpace(os.Getenv("PULSE_AGENT_ID"))
|
||||
envInsecure := strings.TrimSpace(os.Getenv("PULSE_INSECURE_SKIP_VERIFY"))
|
||||
envTags := strings.TrimSpace(os.Getenv("PULSE_TAGS"))
|
||||
envRunOnce := strings.TrimSpace(os.Getenv("PULSE_ONCE"))
|
||||
|
||||
defaultInterval := 30 * time.Second
|
||||
if envInterval != "" {
|
||||
if parsed, err := time.ParseDuration(envInterval); err == nil {
|
||||
defaultInterval = parsed
|
||||
}
|
||||
}
|
||||
|
||||
urlFlag := flag.String("url", envURL, "Pulse server URL (e.g. https://pulse.example.com)")
|
||||
tokenFlag := flag.String("token", envToken, "Pulse API token (required)")
|
||||
intervalFlag := flag.Duration("interval", defaultInterval, "Reporting interval (e.g. 30s, 1m)")
|
||||
hostnameFlag := flag.String("hostname", envHostname, "Override hostname reported to Pulse")
|
||||
agentIDFlag := flag.String("agent-id", envAgentID, "Override agent identifier")
|
||||
insecureFlag := flag.Bool("insecure", parseBool(envInsecure), "Skip TLS certificate verification")
|
||||
runOnceFlag := flag.Bool("once", parseBool(envRunOnce), "Collect and send a single report, then exit")
|
||||
showVersion := flag.Bool("version", false, "Print the agent version and exit")
|
||||
|
||||
var tagFlags multiValue
|
||||
flag.Var(&tagFlags, "tag", "Tag to apply to this host (repeatable)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(hostagent.Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
pulseURL := strings.TrimSpace(*urlFlag)
|
||||
if pulseURL == "" {
|
||||
pulseURL = "http://localhost:7655"
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(*tokenFlag)
|
||||
if token == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: Pulse API token is required (via --token or PULSE_TOKEN)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
interval := *intervalFlag
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
|
||||
tags := gatherTags(envTags, tagFlags)
|
||||
|
||||
return hostagent.Config{
|
||||
PulseURL: pulseURL,
|
||||
APIToken: token,
|
||||
Interval: interval,
|
||||
HostnameOverride: strings.TrimSpace(*hostnameFlag),
|
||||
AgentID: strings.TrimSpace(*agentIDFlag),
|
||||
Tags: tags,
|
||||
InsecureSkipVerify: *insecureFlag,
|
||||
RunOnce: *runOnceFlag,
|
||||
}
|
||||
}
|
||||
|
||||
func gatherTags(env string, flags []string) []string {
|
||||
tags := make([]string, 0)
|
||||
if env != "" {
|
||||
for _, tag := range strings.Split(env, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, tag := range flags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -470,7 +470,25 @@ curl -H "X-API-Token: $ANSIBLE_TOKEN" http://localhost:7655/api/nodes
|
|||
# API_TOKEN=your-secure-api-token ./pulse
|
||||
```
|
||||
|
||||
> **Tip:** Generate a distinct token for each automation workflow (Ansible, Docker agents, CI runners, etc.) so you can revoke one credential without affecting the others.
|
||||
> **Tip:** Generate a distinct token for each automation workflow (Ansible, Docker agents, host agents, CI runners, etc.) so you can revoke one credential without affecting the others.
|
||||
|
||||
### Token Scopes
|
||||
|
||||
API tokens created in the UI can be restricted to the smallest set of permissions required by an integration:
|
||||
|
||||
| Scope | Typical use |
|
||||
|-------|-------------|
|
||||
| `docker:report` | Docker agent submitting host/container telemetry |
|
||||
| `docker:manage` | Docker agent lifecycle commands (restart, stop, etc.) |
|
||||
| `host-agent:report` | Pulse host agent reporting OS metrics |
|
||||
| `monitoring:read` | Read-only access to dashboards, state API, and alert history |
|
||||
| `monitoring:write` | Acknowledge, silence, or clear alerts |
|
||||
| `settings:read` | Fetch configuration snapshots and diagnostics |
|
||||
| `settings:write` | Modify configuration, manage tokens, trigger updates |
|
||||
|
||||
Leaving the scope list empty (or legacy tokens without scopes) grants full access. Tokens generated from specific panels (e.g. **Settings → Agents → Host agents**) automatically apply the relevant scope presets.
|
||||
|
||||
> **Upgrade note:** After upgrading, existing tokens are treated as full-access (`*`). Visit **Settings → Security** to edit each legacy token and assign narrower scopes.
|
||||
|
||||
**Option 2: Basic Authentication**
|
||||
```bash
|
||||
|
|
|
|||
101
docs/HOST_AGENT.md
Normal file
101
docs/HOST_AGENT.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Pulse Host Agent
|
||||
|
||||
The Pulse host agent extends monitoring to standalone servers that do not expose
|
||||
Proxmox or Docker APIs. With it you can surface uptime, OS metadata, CPU load,
|
||||
memory/disk utilisation, and connection health for any Linux, macOS, or Windows
|
||||
machine alongside the rest of your infrastructure.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Pulse `main` (or a release that includes `/api/agents/host/report`)
|
||||
- An API token with the `host-agent:report` scope (create under **Settings → Security**)
|
||||
- Outbound HTTP/HTTPS connectivity from the host back to Pulse
|
||||
|
||||
> ℹ️ The agent only initiates outbound connections; no inbound firewall rules are required.
|
||||
|
||||
## Quick Start
|
||||
|
||||
> Replace `<api-token>` with a Pulse API token limited to the `host-agent:report` scope. Tokens generated from **Settings → Agents → Host agents** already apply this scope.
|
||||
|
||||
### Linux (systemd)
|
||||
|
||||
```bash
|
||||
sudo curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/pulse-host-agent-linux-amd64 \
|
||||
-o /usr/local/bin/pulse-host-agent
|
||||
sudo chmod +x /usr/local/bin/pulse-host-agent
|
||||
sudo /usr/local/bin/pulse-host-agent \
|
||||
--url http://pulse.example.local:7655 \
|
||||
--token <api-token> \
|
||||
--interval 30s
|
||||
```
|
||||
|
||||
For persistence, drop a systemd unit (e.g. `/etc/systemd/system/pulse-host-agent.service`) referencing the same command and enable it with `systemctl enable --now pulse-host-agent`.
|
||||
|
||||
### macOS (launchd)
|
||||
|
||||
```bash
|
||||
sudo curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/pulse-host-agent-darwin-arm64 \
|
||||
-o /usr/local/bin/pulse-host-agent
|
||||
sudo chmod +x /usr/local/bin/pulse-host-agent
|
||||
sudo /usr/local/bin/pulse-host-agent \
|
||||
--url http://pulse.example.local:7655 \
|
||||
--token <api-token> \
|
||||
--interval 30s
|
||||
```
|
||||
|
||||
Create `~/Library/LaunchAgents/com.pulse.host-agent.plist` to keep the agent running between logins:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.pulse.host-agent</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/pulse-host-agent</string>
|
||||
<string>--url</string>
|
||||
<string>http://pulse.example.local:7655</string>
|
||||
<string>--token</string>
|
||||
<string><api-token></string>
|
||||
<string>--interval</string>
|
||||
<string>30s</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardOutPath</key><string>/Users/your-user/Library/Logs/pulse-host-agent.log</string>
|
||||
<key>StandardErrorPath</key><string>/Users/your-user/Library/Logs/pulse-host-agent.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
Load it with `launchctl load ~/Library/LaunchAgents/com.pulse.host-agent.plist`.
|
||||
|
||||
### Windows
|
||||
|
||||
A Windows build will ship shortly. In the meantime run the Linux/WSL binary or compile from source (`GOOS=windows GOARCH=amd64`).
|
||||
|
||||
## Command Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--url` | Pulse base URL (defaults to `http://localhost:7655`) |
|
||||
| `--token` | API token with monitoring write access |
|
||||
| `--interval` | Polling interval (`30s` default) |
|
||||
| `--hostname` | Override reported hostname |
|
||||
| `--agent-id` | Override agent identifier (used as dedupe key) |
|
||||
| `--tag` | Optional tag(s) to annotate the host (repeatable) |
|
||||
| `--insecure` | Skip TLS verification (development/testing only) |
|
||||
| `--once` | Send a single report and exit |
|
||||
|
||||
Run `pulse-host-agent --help` for the full list.
|
||||
|
||||
## Viewing Hosts
|
||||
|
||||
- **Settings → Agents → Host agents** lists every reporting host and provides ready-made install commands.
|
||||
- The **Servers** tab surfaces host telemetry alongside Proxmox/Docker data in the main dashboard.
|
||||
|
||||
## Updating
|
||||
|
||||
Since the agent is a single static binary, updates are as simple as replacing the file and restarting your launchd/systemd unit. The Settings pane always links to the latest release artefacts.
|
||||
98
docs/development/API_TOKEN_SCOPES.md
Normal file
98
docs/development/API_TOKEN_SCOPES.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# API Token Scope Design Brief
|
||||
|
||||
## Objective
|
||||
Introduce scoped API tokens so administrators can grant the minimum necessary permissions to each integration (Docker agent, host agent, future platform agents, automation scripts, etc.). This replaces today’s “all-or-nothing” tokens and provides safer rotation/revocation paths.
|
||||
|
||||
## Security Rationale
|
||||
- Agent and automation tokens are often deployed on hosts or third-party services we do not fully trust. If one leaks today, the attacker inherits full administrator powers (issuing new tokens, mutating settings, triggering installs/updates, etc.).
|
||||
- Constraining tokens to the minimal scope limits the blast radius: a compromised reporting agent can only submit telemetry, not reconfigure Pulse or other integrations.
|
||||
- Customers operating in regulated or multi-team environments increasingly ask for auditable least-privilege controls. Scopes give us the primitives to surface warnings on over-privileged tokens and eventually add rotation workflows.
|
||||
- The feature still has to earn adoption, so pair the technical work with UI nudges and reporting that highlight “Full access” tokens and encourage admins to narrow permissions.
|
||||
|
||||
## Requirements Overview
|
||||
|
||||
1. **Token Model Changes**
|
||||
- Each API token record stores a list of scopes (strings) or a bitmask. Recommended canonical strings:
|
||||
- `monitoring:read`
|
||||
- `monitoring:write`
|
||||
- `docker:report`
|
||||
- `docker:manage`
|
||||
- `host-agent:report`
|
||||
- `settings:read`
|
||||
- `settings:write`
|
||||
- `*` (legacy full-access sentinel; backend accepts for migration/edit flows but the UI should not expose it)
|
||||
- Existing tokens must remain valid. Treat missing scopes as `["*"]` (full access) until the admin edits them.
|
||||
|
||||
2. **Persistence & Migration**
|
||||
- Extend the token persistence layer (currently BoltDB JSON) to include `scopes: []string`.
|
||||
- On startup, detect tokens without the new field and default to `["*"]`.
|
||||
- Expose the complete scope list when returning token metadata (internal API used by Settings UI).
|
||||
|
||||
3. **Middleware Enforcement**
|
||||
- Add a helper `RequireScope(scope string)` that checks the request’s token record for `scope` or `*`.
|
||||
- Apply the helper according to the table below:
|
||||
|
||||
| Endpoint (or group) | HTTP verbs | Required scope(s) | Notes |
|
||||
|-------------------------------------------------|-------------------------------------|-----------------------------|------------------------------------------------------|
|
||||
| `/api/agents/docker/report` | `POST` | `docker:report` | Docker agent heartbeat payloads |
|
||||
| `/api/agents/docker/commands/*` | `POST` | `docker:manage` (optional) | If we expose command ack/management over tokens |
|
||||
| `/api/agents/docker/hosts/*` | `DELETE`, `PUT`, `POST` | `docker:manage` | Admin actions for Docker hosts |
|
||||
| `/api/agents/host/report` | `POST` | `host-agent:report` | Host agent reporting |
|
||||
| `/api/state` | `GET` | `monitoring:read` | General state polling (if token-authenticated) |
|
||||
| `/api/alerts/*` | `GET` | `monitoring:read` | Alerts reading APIs |
|
||||
| `/api/alerts/*` (mutations) | `POST`, `PUT`, `DELETE` | `monitoring:write` | Acknowledge, silence, etc. |
|
||||
| `/api/settings/*` | `GET` | `settings:read` | Settings reads via API |
|
||||
| `/api/settings/*` | `POST`, `PUT`, `DELETE`, `PATCH` | `settings:write` | Any settings mutation |
|
||||
| `/api/security/tokens*` | all verbs | _n/a (session only)_ | Leave browser-session only; do not allow API tokens yet |
|
||||
| `/api/install/*`, `/api/updates/*` (mutations) | `POST`, `PUT` | `settings:write` | Sensitive operational endpoints |
|
||||
|
||||
(More endpoints can be added as required; start with the rows above and expand during implementation.)
|
||||
- Maintain compatibility for admin sessions (browser login) which continue to bypass token checks.
|
||||
|
||||
4. **Token Generation API**
|
||||
- Update `POST /api/security/tokens` to accept a `scopes` array.
|
||||
- Validation rules:
|
||||
- Reject unknown scope identifiers (except the `*` sentinel described above).
|
||||
- If the array is omitted (legacy callers), default to `['*']` (full access) to preserve backward compatibility.
|
||||
- If the array is provided but empty, reject with a 400 (“select at least one scope or delete the token”).
|
||||
- Reject mixed arrays that contain both `'*'` and explicit scopes; if the UI submits such a payload, return a 400 with guidance (“either all scopes or full access”).
|
||||
- Include the scope list in the response payload so the UI can display it.
|
||||
|
||||
5. **UI/UX Adjustments**
|
||||
- **Settings → Security** panel:
|
||||
- When generating or editing a token, show a multi-select with friendly labels (“Docker agent reporting”, “Host agent reporting”, etc.).
|
||||
- Display the scope summary in the token list (e.g. badges).
|
||||
- For legacy tokens (implicit `*`), show “Full access” and allow editing to reduce scope.
|
||||
- **Docker Agents / Host Agents screens:**
|
||||
- When requesting a token:
|
||||
- Docker: pre-select both `docker:report` (always) and `docker:manage` if the user needs lifecycle commands (hide manage behind a toggle if desired).
|
||||
- Host agent: pre-select `host-agent:report`.
|
||||
- Warn if the stored token lacks the required scope (fallback to showing `<api-token>` placeholder).
|
||||
|
||||
6. **Testing**
|
||||
- Unit tests covering:
|
||||
- Scope parsing/migration
|
||||
- Middleware checks (token with/without required scope)
|
||||
- Integration tests for agent endpoints verifying 403 on missing scope.
|
||||
|
||||
7. **Documentation**
|
||||
- Update `README.md` and relevant docs (e.g. `docs/CONFIGURATION.md`, `docs/HOST_AGENT.md`, Docker docs) to explain scoped tokens.
|
||||
- Provide an upgrade note for existing installations (“legacy tokens default to full access; edit to restrict scope”).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Use constants for scope strings to avoid typos throughout the codebase.
|
||||
- Token middleware already retrieves `APITokenRecord`; that struct should grow a `Scopes []string` field with helper methods (`HasScope`).
|
||||
- For future extensibility, keep the scope checks granular but simple (string equality) rather than regex matching.
|
||||
- Ensure the Settings UI gracefully handles lack of admin privileges (disable scope selection, show hint).
|
||||
- Update agent commands (Docker/Host) to mention required scope in their description.
|
||||
- Guardrails: the backend should never auto-insert `*` once a scoped token exists, and any admin edit that clears all scopes should surface a clear “delete token or assign scopes” decision.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Scoped tokens persisted and surfaced via API.
|
||||
- Middleware rejects tokens missing required scope.
|
||||
- UI can create, edit, and display scoped tokens; agent panels auto-fill only when valid.
|
||||
- Documentation updated; existing tokens remain functional without manual migration.
|
||||
|
||||
Once implemented delete this doc.
|
||||
|
|
@ -21,6 +21,7 @@ import Replication from './components/Replication/Replication';
|
|||
import Settings from './components/Settings/Settings';
|
||||
import { Alerts } from './pages/Alerts';
|
||||
import { DockerHosts } from './components/Docker/DockerHosts';
|
||||
import { ServersOverview } from './components/Hosts/ServersOverview';
|
||||
import { ToastContainer } from './components/Toast/Toast';
|
||||
import NotificationContainer from './components/NotificationContainer';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
|
|
@ -42,6 +43,7 @@ import type { State } from '@/types/api';
|
|||
import MailGateway from './components/PMG/MailGateway';
|
||||
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
|
||||
import { DockerIcon } from '@/components/icons/DockerIcon';
|
||||
import { ServersIcon } from '@/components/icons/ServersIcon';
|
||||
import { AlertsIcon } from '@/components/icons/AlertsIcon';
|
||||
import { SettingsGearIcon } from '@/components/icons/SettingsGearIcon';
|
||||
import { TokenRevealDialog } from './components/TokenRevealDialog';
|
||||
|
|
@ -81,6 +83,17 @@ function DockerRoute() {
|
|||
return <DockerHosts hosts={hosts()} activeAlerts={activeAlerts} />;
|
||||
}
|
||||
|
||||
function HostsRoute() {
|
||||
const wsContext = useContext(WebSocketContext);
|
||||
if (!wsContext) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
const { state } = wsContext;
|
||||
return (
|
||||
<ServersOverview hosts={state.hosts ?? []} connectionHealth={state.connectionHealth ?? {}} />
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const TooltipRoot = createTooltipSystem();
|
||||
const owner = getOwner();
|
||||
|
|
@ -616,6 +629,7 @@ function App() {
|
|||
<Route path="/storage" component={() => <Navigate href="/proxmox/storage" />} />
|
||||
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
|
||||
<Route path="/docker" component={DockerRoute} />
|
||||
<Route path="/servers" component={HostsRoute} />
|
||||
<Route path="/alerts/*" component={Alerts} />
|
||||
<Route
|
||||
path="/settings/*"
|
||||
|
|
@ -685,11 +699,13 @@ function AppLayout(props: {
|
|||
const path = location.pathname;
|
||||
if (path.startsWith('/proxmox')) return 'proxmox';
|
||||
if (path.startsWith('/docker')) return 'docker';
|
||||
if (path.startsWith('/servers')) return 'servers';
|
||||
if (path.startsWith('/alerts')) return 'alerts';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
return 'proxmox';
|
||||
};
|
||||
const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0);
|
||||
const hasServers = createMemo(() => (props.state().hosts?.length ?? 0) > 0);
|
||||
const hasProxmoxHosts = createMemo(
|
||||
() =>
|
||||
(props.state().nodes?.length ?? 0) > 0 ||
|
||||
|
|
@ -709,6 +725,12 @@ function AppLayout(props: {
|
|||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (hasServers()) {
|
||||
markPlatformSeen('servers');
|
||||
}
|
||||
});
|
||||
|
||||
const platformTabs = createMemo(() => {
|
||||
return [
|
||||
{
|
||||
|
|
@ -735,6 +757,18 @@ function AppLayout(props: {
|
|||
<DockerIcon class="w-4 h-4 shrink-0" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'servers' as const,
|
||||
label: 'Servers',
|
||||
route: '/servers',
|
||||
settingsRoute: '/settings',
|
||||
tooltip: 'Monitor standalone servers with the host agent',
|
||||
enabled: hasServers() || !!seenPlatforms()['servers'],
|
||||
live: hasServers(),
|
||||
icon: (
|
||||
<ServersIcon class="w-4 h-4 shrink-0" />
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface APITokenRecord {
|
|||
suffix: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
export interface CreateAPITokenResponse {
|
||||
|
|
@ -20,10 +21,18 @@ export class SecurityAPI {
|
|||
return response.tokens ?? [];
|
||||
}
|
||||
|
||||
static async createToken(name?: string): Promise<CreateAPITokenResponse> {
|
||||
static async createToken(name?: string, scopes?: string[]): Promise<CreateAPITokenResponse> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (name) {
|
||||
payload.name = name;
|
||||
}
|
||||
if (scopes) {
|
||||
payload.scopes = scopes;
|
||||
}
|
||||
|
||||
return apiFetchJSON<CreateAPITokenResponse>('/api/security/tokens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
156
frontend-modern/src/components/Hosts/ServersOverview.tsx
Normal file
156
frontend-modern/src/components/Hosts/ServersOverview.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { For, Show, createMemo } from 'solid-js';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Host } from '@/types/api';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
|
||||
interface ServersOverviewProps {
|
||||
hosts: Host[];
|
||||
connectionHealth: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
online: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20',
|
||||
degraded: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20',
|
||||
offline: 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/20',
|
||||
};
|
||||
|
||||
const formatStatus = (status: string | undefined) => {
|
||||
if (!status) return 'unknown';
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === 'online' || normalized === 'degraded' || normalized === 'offline') {
|
||||
return normalized;
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
export const ServersOverview: Component<ServersOverviewProps> = (props) => {
|
||||
const sortedHosts = createMemo(() =>
|
||||
[...props.hosts].sort((a, b) => a.hostname.localeCompare(b.hostname)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-100">Servers</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Unified view of standalone hosts reporting via the Pulse host agent.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Show
|
||||
when={sortedHosts().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="No servers reporting yet"
|
||||
description="Install the pulse-host-agent on a Linux, Windows, or macOS machine to have it appear here."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<For each={sortedHosts()}>
|
||||
{(host) => {
|
||||
const status = formatStatus(host.status);
|
||||
const statusClasses =
|
||||
statusClass[status] ??
|
||||
'bg-slate-500/10 text-slate-600 dark:text-slate-300 border border-slate-500/20';
|
||||
const lastSeen = new Date(host.lastSeen || Date.now());
|
||||
const connectionKey = `host-${host.id}`;
|
||||
const isHealthy = props.connectionHealth[connectionKey] ?? status !== 'offline';
|
||||
const memoryUsage =
|
||||
typeof host.memory?.usage === 'number'
|
||||
? Math.round((host.memory.usage + Number.EPSILON) * 10) / 10
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card class="flex flex-col gap-4 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-500 uppercase tracking-wide">
|
||||
{host.platform ?? 'unknown'}
|
||||
</p>
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{host.displayName || host.hostname}
|
||||
</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{host.osName}
|
||||
{host.osVersion ? ` ${host.osVersion}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span class={`rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">CPU Usage</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{typeof host.cpuUsage === 'number' ? `${host.cpuUsage.toFixed(1)}%` : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">Memory</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{host.memory?.total
|
||||
? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}${
|
||||
memoryUsage !== undefined ? ` (${memoryUsage.toFixed(1)}%)` : ''
|
||||
}`
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">Architecture</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{host.architecture ?? '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">Last Seen</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{lastSeen.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<dt class="text-slate-500 dark:text-slate-400">Connection</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{isHealthy ? 'Healthy' : 'Unreachable'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<Show when={host.disks && host.disks.length > 0}>
|
||||
<div class="rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-700/70 dark:bg-slate-900/70">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Storage
|
||||
</p>
|
||||
<ul class="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-300">
|
||||
<For each={host.disks}>
|
||||
{(disk) => (
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="truncate">
|
||||
{disk.mountpoint || disk.device || 'disk'} •{' '}
|
||||
{disk.type ? disk.type.toUpperCase() : '—'}
|
||||
</span>
|
||||
<span>
|
||||
{formatBytes(disk.used ?? 0)} / {formatBytes(disk.total ?? 0)}
|
||||
{typeof disk.usage === 'number'
|
||||
? ` (${disk.usage.toFixed(1)}%)`
|
||||
: ''}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,12 +1,20 @@
|
|||
import { Component, For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import { SecurityAPI, type APITokenRecord } from '@/api/security';
|
||||
import { showError, showSuccess } from '@/utils/toast';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { useWebSocket } from '@/App';
|
||||
import type { DockerHost } from '@/types/api';
|
||||
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
|
||||
import {
|
||||
API_SCOPE_LABELS,
|
||||
API_SCOPE_OPTIONS,
|
||||
DOCKER_MANAGE_SCOPE,
|
||||
DOCKER_REPORT_SCOPE,
|
||||
HOST_AGENT_SCOPE,
|
||||
SETTINGS_READ_SCOPE,
|
||||
SETTINGS_WRITE_SCOPE,
|
||||
} from '@/constants/apiScopes';
|
||||
|
||||
interface APITokenManagerProps {
|
||||
currentTokenHint?: string;
|
||||
|
|
@ -14,6 +22,10 @@ interface APITokenManagerProps {
|
|||
refreshing?: boolean;
|
||||
}
|
||||
|
||||
const SCOPES_DOC_URL =
|
||||
'https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes';
|
||||
const WILDCARD_SCOPE = '*';
|
||||
|
||||
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
const { state } = useWebSocket();
|
||||
const dockerHosts = createMemo<DockerHost[]>(() => state.dockerHosts ?? []);
|
||||
|
|
@ -23,11 +35,11 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
const tokenId = host.tokenId;
|
||||
if (!tokenId) continue;
|
||||
const displayName = host.displayName?.trim() || host.hostname || host.id;
|
||||
const existing = usage.get(tokenId);
|
||||
if (existing) {
|
||||
const previous = usage.get(tokenId);
|
||||
if (previous) {
|
||||
usage.set(tokenId, {
|
||||
count: existing.count + 1,
|
||||
hosts: [...existing.hosts, displayName],
|
||||
count: previous.count + 1,
|
||||
hosts: [...previous.hosts, displayName],
|
||||
});
|
||||
} else {
|
||||
usage.set(tokenId, { count: 1, hosts: [displayName] });
|
||||
|
|
@ -37,12 +49,133 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
});
|
||||
|
||||
const [tokens, setTokens] = createSignal<APITokenRecord[]>([]);
|
||||
const sortedTokens = createMemo(() =>
|
||||
[...tokens()].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
),
|
||||
);
|
||||
const totalTokens = createMemo(() => sortedTokens().length);
|
||||
const wildcardCount = createMemo(() =>
|
||||
sortedTokens().filter((token) => {
|
||||
const scopes = token.scopes;
|
||||
return !scopes || scopes.length === 0 || scopes.includes('*');
|
||||
}).length,
|
||||
);
|
||||
const scopedTokenCount = createMemo(() => totalTokens() - wildcardCount());
|
||||
const hasWildcardTokens = createMemo(() => wildcardCount() > 0);
|
||||
const mostRecentLabel = createMemo(() => {
|
||||
const first = sortedTokens()[0];
|
||||
if (!first) return '—';
|
||||
const timestamp = new Date(first.createdAt).getTime();
|
||||
return Number.isFinite(timestamp) ? formatRelativeTime(timestamp) : '—';
|
||||
});
|
||||
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
|
||||
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
|
||||
const [nameInput, setNameInput] = createSignal('');
|
||||
const tokenRevealState = useTokenRevealState();
|
||||
const [selectedScopes, setSelectedScopes] = createSignal<string[]>([]);
|
||||
|
||||
type ScopeGroup = (typeof API_SCOPE_OPTIONS)[number]['group'];
|
||||
type ScopeOption = (typeof API_SCOPE_OPTIONS)[number];
|
||||
const scopeGroupOrder: ScopeGroup[] = ['Monitoring', 'Agents', 'Settings'];
|
||||
const scopeGroups = createMemo<[ScopeGroup, ScopeOption[]][]>(() => {
|
||||
const grouped: Record<ScopeGroup, ScopeOption[]> = {
|
||||
Monitoring: [],
|
||||
Agents: [],
|
||||
Settings: [],
|
||||
};
|
||||
for (const option of API_SCOPE_OPTIONS) {
|
||||
grouped[option.group].push(option);
|
||||
}
|
||||
return scopeGroupOrder
|
||||
.map((group) => [group, grouped[group]] as [ScopeGroup, ScopeOption[]])
|
||||
.filter(([, options]) => options.length > 0);
|
||||
});
|
||||
|
||||
const isFullAccessSelected = () =>
|
||||
selectedScopes().length === 0 || selectedScopes().includes(WILDCARD_SCOPE);
|
||||
|
||||
const scopePresets: { label: string; scopes: string[]; description: string }[] = [
|
||||
{
|
||||
label: 'Host agent',
|
||||
scopes: [HOST_AGENT_SCOPE],
|
||||
description: 'Allow pulse-host-agent to submit OS, CPU, and disk metrics.',
|
||||
},
|
||||
{
|
||||
label: 'Docker report',
|
||||
scopes: [DOCKER_REPORT_SCOPE],
|
||||
description: 'Permits Docker agents to stream host and container telemetry only.',
|
||||
},
|
||||
{
|
||||
label: 'Docker manage',
|
||||
scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE],
|
||||
description: 'Extends Docker reporting with lifecycle actions (restart, stop, etc.).',
|
||||
},
|
||||
{
|
||||
label: 'Settings read',
|
||||
scopes: [SETTINGS_READ_SCOPE],
|
||||
description: 'Read configuration snapshots and diagnostics without modifying anything.',
|
||||
},
|
||||
{
|
||||
label: 'Settings admin',
|
||||
scopes: [SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE],
|
||||
description: 'Full settings read/write – equivalent to automation with admin privileges.',
|
||||
},
|
||||
];
|
||||
|
||||
const presetMatchesSelection = (presetScopes: string[]) => {
|
||||
const selection = [...selectedScopes()]
|
||||
.filter((scope) => scope !== WILDCARD_SCOPE)
|
||||
.sort();
|
||||
const target = [...presetScopes].sort();
|
||||
if (target.length === 0) {
|
||||
return isFullAccessSelected();
|
||||
}
|
||||
if (selection.length !== target.length) {
|
||||
return false;
|
||||
}
|
||||
return target.every((scope) => selection.includes(scope));
|
||||
};
|
||||
|
||||
const presetButtonBase =
|
||||
'flex w-full items-start justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors';
|
||||
const presetButtonActive =
|
||||
'border-blue-400 ring-1 ring-blue-300 bg-blue-50/70 dark:border-blue-500 dark:ring-blue-400/40 dark:bg-blue-900/20';
|
||||
const presetButtonInactive =
|
||||
'border-gray-300 bg-white hover:border-blue-400 dark:border-gray-600 dark:bg-gray-900/60 dark:hover:border-blue-500';
|
||||
const selectedScopeChips = createMemo(() =>
|
||||
selectedScopes()
|
||||
.filter((scope) => scope !== WILDCARD_SCOPE)
|
||||
.map((scope) => ({
|
||||
value: scope,
|
||||
label: API_SCOPE_LABELS[scope] ?? scope,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
);
|
||||
const [advancedScopesOpen, setAdvancedScopesOpen] = createSignal(false);
|
||||
|
||||
const applyScopePreset = (scopes: string[]) => {
|
||||
const unique = Array.from(new Set(scopes)).filter(Boolean);
|
||||
setSelectedScopes(unique);
|
||||
};
|
||||
const clearScopes = () => setSelectedScopes([]);
|
||||
|
||||
let createSectionRef: HTMLDivElement | undefined;
|
||||
const [createHighlight, setCreateHighlight] = createSignal(false);
|
||||
let highlightTimer: number | undefined;
|
||||
const focusCreateSection = () => {
|
||||
if (!createSectionRef) return;
|
||||
createSectionRef.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setCreateHighlight(true);
|
||||
window.clearTimeout(highlightTimer);
|
||||
highlightTimer = window.setTimeout(() => setCreateHighlight(false), 1600);
|
||||
};
|
||||
onCleanup(() => {
|
||||
if (highlightTimer) window.clearTimeout(highlightTimer);
|
||||
});
|
||||
|
||||
const loadTokens = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -65,7 +198,9 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
setIsGenerating(true);
|
||||
try {
|
||||
const trimmedName = nameInput().trim() || undefined;
|
||||
const { token, record } = await SecurityAPI.createToken(trimmedName);
|
||||
const scopeSelection = [...selectedScopes()].sort();
|
||||
const scopePayload = scopeSelection.length > 0 ? scopeSelection : undefined;
|
||||
const { token, record } = await SecurityAPI.createToken(trimmedName, scopePayload);
|
||||
|
||||
setTokens((prev) => [record, ...prev]);
|
||||
setNewTokenValue(token);
|
||||
|
|
@ -89,7 +224,6 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
} catch (storageErr) {
|
||||
console.warn('Unable to persist API token in localStorage', storageErr);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to generate API token', err);
|
||||
showError('Failed to generate API token');
|
||||
|
|
@ -112,7 +246,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
if (record.name?.trim()) return record.name.trim();
|
||||
if (record.prefix && record.suffix) return `${record.prefix}…${record.suffix}`;
|
||||
if (record.prefix) return `${record.prefix}…`;
|
||||
return 'unnamed token';
|
||||
return 'untitled token';
|
||||
};
|
||||
|
||||
const handleDelete = async (record: APITokenRecord) => {
|
||||
|
|
@ -120,7 +254,6 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
const displayName = tokenNameForDialog(record);
|
||||
|
||||
let message = `Revoke token "${displayName}"? Any agents or integrations using it will stop working.`;
|
||||
|
||||
if (usage) {
|
||||
const hostListPreview = usage.hosts.slice(0, 5).join(', ');
|
||||
const extraCount = usage.hosts.length - 5;
|
||||
|
|
@ -131,8 +264,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
message = `Token "${displayName}" is currently used by ${hostCountLabel}.\nHosts: ${hostSummary}\n\nRevoking it will cause those agents to stop reporting until you update them with a new token.\n\nContinue?`;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(message);
|
||||
if (!confirmed) return;
|
||||
if (!window.confirm(message)) return;
|
||||
|
||||
try {
|
||||
await SecurityAPI.deleteToken(record.id);
|
||||
|
|
@ -170,45 +302,197 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/50 rounded-lg">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="API tokens"
|
||||
description="Generate or revoke access tokens for automation and agents"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
/>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">API tokens</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Authenticate host agents, Docker integrations, and automation pipelines with scoped access.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={focusCreateSection}
|
||||
class="inline-flex items-center gap-2 self-start rounded-md border border-blue-200 bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Generate token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<Show when={props.refreshing}>
|
||||
<div class="flex items-center gap-2 rounded-md bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 px-3 py-2 text-xs text-blue-800 dark:text-blue-200">
|
||||
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="4" stroke="currentColor" />
|
||||
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke-width="4" stroke-linecap="round" stroke="currentColor" />
|
||||
</svg>
|
||||
<span>Refreshing security status…</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.refreshing}>
|
||||
<div class="flex items-center gap-2 rounded-md border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/30 px-3 py-2 text-xs text-blue-800 dark:text-blue-200">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="4" stroke="currentColor" />
|
||||
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke-width="4" stroke-linecap="round" stroke="currentColor" />
|
||||
</svg>
|
||||
<span>Refreshing security status…</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card padding="lg" class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">Active tokens</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Rotate tokens regularly and scope them to the minimum access required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/40 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Active tokens
|
||||
</p>
|
||||
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">{totalTokens()}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/40 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Scoped tokens
|
||||
</p>
|
||||
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">{scopedTokenCount()}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/40 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{hasWildcardTokens() ? 'Full access tokens' : 'Last generated'}
|
||||
</p>
|
||||
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{hasWildcardTokens()
|
||||
? wildcardCount()
|
||||
: totalTokens() > 0
|
||||
? mostRecentLabel()
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!loading() && totalTokens() > 0}
|
||||
fallback={
|
||||
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-700/70 bg-white/60 dark:bg-gray-900/30 p-5 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p class="mb-3">
|
||||
No tokens yet. Generate one to authenticate agents and automation.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={focusCreateSection}
|
||||
class="inline-flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200"
|
||||
>
|
||||
Generate token
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/60">
|
||||
<tr>
|
||||
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Label</th>
|
||||
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Token hint</th>
|
||||
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Scopes</th>
|
||||
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Created</th>
|
||||
<th class="py-2 px-3 text-left font-medium text-gray-600 dark:text-gray-400">Last used</th>
|
||||
<th class="py-2 px-3 text-right font-medium text-gray-600 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={sortedTokens()}>
|
||||
{(token) => {
|
||||
const usage = dockerTokenUsage().get(token.id);
|
||||
const hostTitle = usage ? usage.hosts.join(', ') : undefined;
|
||||
const hostPreview = usage ? usage.hosts.slice(0, 2).join(', ') : '';
|
||||
const extraCount = usage ? usage.hosts.length - 2 : 0;
|
||||
const hostSummary =
|
||||
usage && usage.count === 1
|
||||
? usage.hosts[0]
|
||||
: usage
|
||||
? `${hostPreview}${extraCount > 0 ? `, +${extraCount} more` : ''}`
|
||||
: '';
|
||||
const hostCountLabel =
|
||||
usage && usage.count === 1 ? 'host' : usage ? 'hosts' : '';
|
||||
const rawScopes = token.scopes && token.scopes.length > 0 ? token.scopes : ['*'];
|
||||
const scopeBadges = rawScopes.includes('*')
|
||||
? [{ value: '*', label: 'Full access' }]
|
||||
: rawScopes.map((scope) => ({
|
||||
value: scope,
|
||||
label: API_SCOPE_LABELS[scope] ?? scope,
|
||||
}));
|
||||
const rowIsWildcard = scopeBadges.some((scope) => scope.value === '*');
|
||||
|
||||
return (
|
||||
<tr
|
||||
class={`transition-colors ${rowIsWildcard ? 'bg-amber-50/60 dark:bg-amber-900/15' : 'bg-white dark:bg-gray-900/20'} hover:bg-gray-50 dark:hover:bg-gray-800/60`}
|
||||
>
|
||||
<td class="py-3 px-3 text-gray-900 dark:text-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{token.name || 'Untitled token'}</span>
|
||||
<Show when={usage}>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
Docker
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={usage}>
|
||||
<div
|
||||
class="mt-1 text-xs text-blue-700 dark:text-blue-300"
|
||||
title={hostTitle}
|
||||
>
|
||||
Used by Docker {hostCountLabel}: {hostSummary}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="py-3 px-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
{tokenHint(token)}
|
||||
</td>
|
||||
<td class="py-3 px-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={scopeBadges}>
|
||||
{(scope) => {
|
||||
const isWildcard = scope.value === '*';
|
||||
const badgeClass = isWildcard
|
||||
? 'inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-200'
|
||||
: 'inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-[11px] font-medium text-gray-700 dark:text-gray-200';
|
||||
return (
|
||||
<span class={badgeClass} title={scope.value}>
|
||||
{scope.label}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-3 text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeTime(new Date(token.createdAt).getTime())}
|
||||
</td>
|
||||
<td class="py-3 px-3 text-gray-600 dark:text-gray-400">
|
||||
{token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'}
|
||||
</td>
|
||||
<td class="py-3 px-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(token)}
|
||||
class="inline-flex items-center rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
{/* Generated token reminder - only show when dialog is not already visible */}
|
||||
<Show when={newTokenValue() && !isRevealActiveForCurrentToken()}>
|
||||
<div class="space-y-3 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/30 p-4">
|
||||
<Card padding="lg" tone="success" class="space-y-3 border border-green-300 dark:border-green-700">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 p-2">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -216,8 +500,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
<h3 class="text-base font-semibold text-green-900 dark:text-green-100">
|
||||
Token ready to copy
|
||||
</h3>
|
||||
<p class="text-sm text-green-800 dark:text-green-200 leading-snug">
|
||||
We keep the full token inside a secure dialog. Reopen it if you still need to copy the value before navigating away.
|
||||
<p class="text-sm leading-snug text-green-800 dark:text-green-200">
|
||||
Tokens are only shown once. Copy it now or store it securely before you leave this page.
|
||||
</p>
|
||||
<Show when={newTokenRecord()}>
|
||||
<p class="text-xs text-green-900/80 dark:text-green-200/80">
|
||||
|
|
@ -237,9 +521,9 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
<button
|
||||
type="button"
|
||||
onClick={reopenTokenDialog}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-sm font-semibold px-4 py-2 transition-colors shadow-sm"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-green-700"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7h4m0 0v4m0-4l-6 6-4-4-6 6" />
|
||||
</svg>
|
||||
Show token dialog
|
||||
|
|
@ -250,125 +534,300 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
|||
setNewTokenValue(null);
|
||||
setNewTokenRecord(null);
|
||||
}}
|
||||
class="inline-flex items-center rounded-md border border-green-400 dark:border-green-700 px-4 py-2 text-sm font-medium text-green-800 dark:text-green-200 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors"
|
||||
class="inline-flex items-center rounded-md border border-green-500 px-4 py-2 text-sm font-medium text-green-800 transition-colors hover:bg-green-100 dark:border-green-600 dark:text-green-200 dark:hover:bg-green-900/40"
|
||||
>
|
||||
Dismiss reminder
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<div class="text-xs text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
Issue a dedicated token for each host or automation. That way, if a system is compromised, you can revoke just its token without disrupting anything else.
|
||||
</div>
|
||||
<Card
|
||||
padding="lg"
|
||||
class={`space-y-6 transition-shadow duration-300 ${createHighlight() ? 'ring-2 ring-blue-500/60 dark:ring-blue-400/60 shadow-lg' : ''}`}
|
||||
ref={(el: HTMLDivElement) => {
|
||||
createSectionRef = el;
|
||||
}}
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">Generate new token</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Tokens are only displayed once. Follow the steps below to create a scoped credential.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate new token</h3>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="api-token-name"
|
||||
type="text"
|
||||
value={nameInput()}
|
||||
onInput={(event) => setNameInput(event.currentTarget.value)}
|
||||
placeholder="e.g., docker-host-1"
|
||||
class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<ol class="space-y-6 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li class="flex gap-3">
|
||||
<div class="mt-1 flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold uppercase text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
1
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Name the token</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Use something descriptive so you can identify the integration later.
|
||||
</p>
|
||||
<input
|
||||
id="api-token-name"
|
||||
type="text"
|
||||
value={nameInput()}
|
||||
onInput={(event) => setNameInput(event.currentTarget.value)}
|
||||
placeholder="e.g., docker-host-1"
|
||||
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="mt-1 flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold uppercase text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
2
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Set a baseline scope</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Start with a preset that matches the integration, or choose full access if you plan to trim later.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class={`${presetButtonBase} ${isFullAccessSelected() ? presetButtonActive : presetButtonInactive}`}
|
||||
onClick={clearScopes}
|
||||
>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Full access</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Legacy wildcard token – grants every permission.</p>
|
||||
</div>
|
||||
<span class={`text-xs font-semibold uppercase ${isFullAccessSelected() ? 'text-blue-600 dark:text-blue-300' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{isFullAccessSelected() ? 'Selected' : 'Reset'}
|
||||
</span>
|
||||
</button>
|
||||
<For each={scopePresets}>
|
||||
{(preset) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`${presetButtonBase} ${presetMatchesSelection(preset.scopes) ? presetButtonActive : presetButtonInactive}`}
|
||||
onClick={() => applyScopePreset(preset.scopes)}
|
||||
>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">{preset.label}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{preset.description}</p>
|
||||
</div>
|
||||
<span class={`text-xs font-semibold uppercase ${presetMatchesSelection(preset.scopes) ? 'text-blue-600 dark:text-blue-300' : 'text-blue-500 dark:text-blue-300/80'}`}>
|
||||
{presetMatchesSelection(preset.scopes) ? 'Selected' : 'Apply'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="rounded-md border border-dashed border-gray-300 bg-white px-3 py-2 text-xs text-gray-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-300">
|
||||
<span class="block text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Current selection
|
||||
</span>
|
||||
<Show
|
||||
when={!isFullAccessSelected() && selectedScopeChips().length > 0}
|
||||
fallback={<span class="block pt-1 text-xs text-gray-700 dark:text-gray-200">Full access (wildcard)</span>}
|
||||
>
|
||||
<div class="pt-1 flex flex-wrap gap-1.5">
|
||||
<For each={selectedScopeChips()}>
|
||||
{(chip) => (
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
{chip.label}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="mt-1 flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold uppercase text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
3
|
||||
</div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Fine-tune permissions</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Toggle advanced scopes if the integration needs additional access beyond the preset.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Show when={!isFullAccessSelected() && selectedScopeChips().length > 0}>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={selectedScopeChips()}>
|
||||
{(chip) => (
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300">
|
||||
{chip.value}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedScopesOpen((open) => !open)}
|
||||
class={`inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-wide transition-colors ${advancedScopesOpen() ? 'text-blue-600 dark:text-blue-300' : 'text-gray-600 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-300'}`}
|
||||
>
|
||||
<svg
|
||||
class={`h-3 w-3 transition-transform ${advancedScopesOpen() ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{advancedScopesOpen() ? 'Hide advanced scopes' : 'Add or remove individual scopes'}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={advancedScopesOpen()}>
|
||||
<div class="space-y-4">
|
||||
<For each={scopeGroups()}>
|
||||
{([group, options]) => (
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{group}
|
||||
</p>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<For each={options}>
|
||||
{(option) => {
|
||||
const inputId = `scope-${option.value.replace(/[:]/g, '-')}`;
|
||||
const checked = () => selectedScopes().includes(option.value);
|
||||
return (
|
||||
<label
|
||||
for={inputId}
|
||||
class={`flex items-start gap-3 rounded-lg border px-3 py-2 text-sm transition-colors ${checked() ? 'border-blue-400 bg-blue-50/70 ring-1 ring-blue-300 dark:border-blue-500 dark:bg-blue-900/30 dark:ring-blue-400/40' : 'border-gray-200 bg-white hover:border-blue-400 dark:border-gray-600 dark:bg-gray-900/50 dark:hover:border-blue-500'}`}
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
|
||||
checked={checked()}
|
||||
onInput={(event) => {
|
||||
const isChecked = event.currentTarget.checked;
|
||||
setSelectedScopes((prev) => {
|
||||
if (isChecked) {
|
||||
if (prev.includes(option.value)) return prev;
|
||||
return [...prev, option.value];
|
||||
}
|
||||
return prev.filter((value) => value !== option.value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{option.label}
|
||||
</p>
|
||||
<Show when={option.description}>
|
||||
<p class="text-xs leading-snug text-gray-500 dark:text-gray-400">
|
||||
{option.description}
|
||||
</p>
|
||||
</Show>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300">
|
||||
{option.value}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
class="inline-flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating()}
|
||||
>
|
||||
{isGenerating() ? 'Generating…' : 'Generate'}
|
||||
{isGenerating() ? (
|
||||
<>
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="4" stroke="currentColor" />
|
||||
<path class="opacity-75" d="M4 12a8 8 0 018-8" stroke-width="4" stroke-linecap="round" stroke="currentColor" />
|
||||
</svg>
|
||||
Generating…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Generate token
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Show when={newTokenValue() && !isRevealActiveForCurrentToken()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reopenTokenDialog}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
>
|
||||
View last token
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Active tokens</h3>
|
||||
<Show
|
||||
when={!loading() && tokens().length > 0}
|
||||
fallback={
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
No API tokens yet. Generate one above to get started.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Label</th>
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Token hint</th>
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Created</th>
|
||||
<th class="text-left py-2 px-3 font-medium text-gray-600 dark:text-gray-400">Last used</th>
|
||||
<th class="py-2 px-3 text-right font-medium text-gray-600 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={tokens()}>
|
||||
{(token) => {
|
||||
const usage = dockerTokenUsage().get(token.id);
|
||||
const hostTitle = usage ? usage.hosts.join(', ') : undefined;
|
||||
const hostPreview = usage ? usage.hosts.slice(0, 2).join(', ') : '';
|
||||
const extraCount = usage ? usage.hosts.length - 2 : 0;
|
||||
const hostSummary =
|
||||
usage && usage.count === 1
|
||||
? usage.hosts[0]
|
||||
: usage
|
||||
? `${hostPreview}${extraCount > 0 ? `, +${extraCount} more` : ''}`
|
||||
: '';
|
||||
const hostCountLabel =
|
||||
usage && usage.count === 1 ? 'host' : usage ? 'hosts' : '';
|
||||
<Show when={!loading() && hasWildcardTokens()}>
|
||||
<Card padding="lg" tone="warning" class="space-y-3 border border-amber-300 dark:border-amber-700">
|
||||
<h4 class="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
||||
Full access tokens detected
|
||||
</h4>
|
||||
<p class="text-xs text-amber-900/90 dark:text-amber-100/90">
|
||||
Edit existing tokens to assign scopes, or generate replacements with the presets above so compromised credentials can’t control everything.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={focusCreateSection}
|
||||
class="inline-flex w-fit items-center gap-2 rounded-md border border-amber-300 bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-200 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-100"
|
||||
>
|
||||
Review scopes
|
||||
</button>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td class="py-2 px-3 text-gray-900 dark:text-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{token.name || 'Untitled token'}</span>
|
||||
<Show when={usage}>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
Docker
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={usage}>
|
||||
<div
|
||||
class="mt-1 text-xs text-blue-700 dark:text-blue-300"
|
||||
title={hostTitle}
|
||||
>
|
||||
Used by Docker {hostCountLabel}: {hostSummary}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
{tokenHint(token)}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeTime(new Date(token.createdAt).getTime())}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">
|
||||
{token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(token)}
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-medium text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Card padding="lg" tone="muted" class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Good practices</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li class="flex gap-3">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
|
||||
<span>Issue separate tokens for Docker agents, host agents, and automation pipelines so you can revoke them independently.</span>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
|
||||
<span>Rotate tokens on a schedule and remove ones that haven’t been used recently.</span>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
|
||||
<span>
|
||||
View the{' '}
|
||||
<a
|
||||
class="font-medium text-blue-600 underline decoration-transparent transition-colors hover:decoration-blue-500 dark:text-blue-300"
|
||||
href={SCOPES_DOC_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
scoped token guide
|
||||
</a>{' '}
|
||||
for the full list of available permissions.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default APITokenManager;
|
||||
|
|
|
|||
378
frontend-modern/src/components/Settings/HostAgents.tsx
Normal file
378
frontend-modern/src/components/Settings/HostAgents.tsx
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { Component, For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { useWebSocket } from '@/App';
|
||||
import type { Host } from '@/types/api';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import CopyButton from '@/components/shared/CopyButton';
|
||||
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
|
||||
import { SecurityAPI } from '@/api/security';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { showTokenReveal } from '@/stores/tokenReveal';
|
||||
import { HOST_AGENT_SCOPE } from '@/constants/apiScopes';
|
||||
|
||||
type HostAgentVariant = 'all' | 'linux' | 'macos' | 'windows';
|
||||
|
||||
interface HostAgentsProps {
|
||||
variant?: HostAgentVariant;
|
||||
}
|
||||
|
||||
const RELEASE_BASE = 'https://github.com/rcourtman/Pulse/releases/latest/download';
|
||||
|
||||
const TOKEN_PLACEHOLDER = '<api-token>';
|
||||
|
||||
const pulseUrl = () => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:7655';
|
||||
const { protocol, hostname, port } = window.location;
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
|
||||
};
|
||||
|
||||
const commandsByVariant: Record<HostAgentVariant, { title: string; description: string; snippets: { label: string; command: string; note?: string | JSX.Element }[] }> = {
|
||||
all: {
|
||||
title: 'Installation quick start',
|
||||
description:
|
||||
'Generate an API token from Settings → Security with the host agent reporting scope, then replace the highlighted token placeholder. Agents only require outbound HTTP(S) access to Pulse.',
|
||||
snippets: [
|
||||
{
|
||||
label: 'Linux (systemd)',
|
||||
command: [
|
||||
`curl -fsSL ${RELEASE_BASE}/pulse-host-agent-linux-amd64 -o /usr/local/bin/pulse-host-agent`,
|
||||
'sudo chmod +x /usr/local/bin/pulse-host-agent',
|
||||
`sudo /usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
|
||||
].join(' && '),
|
||||
},
|
||||
{
|
||||
label: 'macOS (launchd)',
|
||||
command: [
|
||||
`curl -fsSL ${RELEASE_BASE}/pulse-host-agent-darwin-arm64 -o /usr/local/bin/pulse-host-agent`,
|
||||
'sudo chmod +x /usr/local/bin/pulse-host-agent',
|
||||
`sudo /usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
|
||||
].join(' && '),
|
||||
note: (
|
||||
<span>
|
||||
Create <code>~/Library/LaunchAgents/com.pulse.host-agent.plist</code> to keep the agent running between logins.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Ad-hoc execution',
|
||||
command: `/usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
|
||||
},
|
||||
],
|
||||
},
|
||||
linux: {
|
||||
title: 'Install on Linux',
|
||||
description:
|
||||
'Download the static binary, make it executable, and (optionally) register it as a systemd service. Replace the token placeholder with an API token scoped for host agent reporting.',
|
||||
snippets: [
|
||||
{
|
||||
label: 'Install + enable (systemd)',
|
||||
command: [
|
||||
`curl -fsSL ${RELEASE_BASE}/pulse-host-agent-linux-amd64 -o /usr/local/bin/pulse-host-agent`,
|
||||
'sudo chmod +x /usr/local/bin/pulse-host-agent',
|
||||
`sudo /usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
|
||||
].join(' && '),
|
||||
note: (
|
||||
<span>
|
||||
For persistence, create <code>/etc/systemd/system/pulse-host-agent.service</code> and enable it with{' '}
|
||||
<code>systemctl enable --now pulse-host-agent</code>.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
macos: {
|
||||
title: 'Install on macOS',
|
||||
description:
|
||||
'Use the universal macOS build (arm64) with an API token that grants the host agent reporting scope, then register it via launchd for continuous reporting.',
|
||||
snippets: [
|
||||
{
|
||||
label: 'Install binary',
|
||||
command: [
|
||||
`curl -fsSL ${RELEASE_BASE}/pulse-host-agent-darwin-arm64 -o /usr/local/bin/pulse-host-agent`,
|
||||
'sudo chmod +x /usr/local/bin/pulse-host-agent',
|
||||
].join(' && '),
|
||||
},
|
||||
{
|
||||
label: 'Launchd service',
|
||||
command: `launchctl load ~/Library/LaunchAgents/com.pulse.host-agent.plist`,
|
||||
note: (
|
||||
<span>
|
||||
Create a plist pointing to{' '}
|
||||
<code>/usr/local/bin/pulse-host-agent --url {pulseUrl()} --token {TOKEN_PLACEHOLDER} --interval 30s</code> to run at login.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
windows: {
|
||||
title: 'Install on Windows',
|
||||
description:
|
||||
'Native Windows builds are coming soon. In the interim you can run the Linux binary under WSL or compile from source using an API token scoped for host agent reporting.',
|
||||
snippets: [
|
||||
{
|
||||
label: 'Compile from source (PowerShell)',
|
||||
command: [
|
||||
'git clone https://github.com/rcourtman/Pulse.git',
|
||||
'cd Pulse',
|
||||
'go build -o pulse-host-agent.exe ./cmd/pulse-host-agent',
|
||||
`./pulse-host-agent.exe --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
|
||||
].join(' && '),
|
||||
note: (
|
||||
<span>
|
||||
Consider registering the executable as a Windows Service via <code>sc.exe</code> or NSSM once native artefacts ship.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const platformFilters: Record<HostAgentVariant, string[] | null> = {
|
||||
all: null,
|
||||
linux: ['linux'],
|
||||
macos: ['macos'],
|
||||
windows: ['windows'],
|
||||
};
|
||||
|
||||
export const HostAgents: Component<HostAgentsProps> = (props) => {
|
||||
const variant: HostAgentVariant = props.variant ?? 'all';
|
||||
const { state } = useWebSocket();
|
||||
const [apiToken, setApiToken] = createSignal('');
|
||||
const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
|
||||
const [tokenAccessDenied, setTokenAccessDenied] = createSignal(false);
|
||||
|
||||
const hosts = createMemo(() => {
|
||||
const list = state.hosts ?? [];
|
||||
const filters = platformFilters[variant];
|
||||
const filtered = filters ? list.filter((host) => filters.includes((host.platform ?? '').toLowerCase())) : list;
|
||||
return [...filtered].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''));
|
||||
});
|
||||
|
||||
const renderTags = (host: Host) => {
|
||||
const tags = host.tags ?? [];
|
||||
if (!tags.length) return '—';
|
||||
return tags.join(', ');
|
||||
};
|
||||
|
||||
const installMeta = commandsByVariant[variant];
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const stored = window.localStorage.getItem('apiToken');
|
||||
if (stored) {
|
||||
setApiToken(stored);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Unable to read API token from localStorage', err);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const token = apiToken();
|
||||
try {
|
||||
if (token) {
|
||||
window.localStorage.setItem('apiToken', token);
|
||||
} else {
|
||||
window.localStorage.removeItem('apiToken');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Unable to persist API token in localStorage', err);
|
||||
}
|
||||
});
|
||||
|
||||
const generateToken = async () => {
|
||||
if (isGeneratingToken()) return;
|
||||
if (tokenAccessDenied()) {
|
||||
notificationStore.error('Administrator access required to generate host agent tokens.', 6000);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingToken(true);
|
||||
try {
|
||||
const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`;
|
||||
const { token, record } = await SecurityAPI.createToken(defaultName, [HOST_AGENT_SCOPE]);
|
||||
setApiToken(token);
|
||||
showTokenReveal({
|
||||
token,
|
||||
record,
|
||||
source: 'host-agent',
|
||||
note: 'Copy this token into the host agent install command or store it securely for automation.',
|
||||
});
|
||||
notificationStore.success('Created host agent API token with reporting scope.', 6000);
|
||||
} catch (err) {
|
||||
console.error('Failed to create host agent token', err);
|
||||
if (err instanceof Error && /authentication required|forbidden/i.test(err.message)) {
|
||||
setTokenAccessDenied(true);
|
||||
notificationStore.error('Sign in with an administrator account to generate tokens here.', 6000);
|
||||
} else {
|
||||
notificationStore.error('Failed to generate API token', 6000);
|
||||
}
|
||||
} finally {
|
||||
setIsGeneratingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cardTitle = () => {
|
||||
switch (variant) {
|
||||
case 'linux':
|
||||
return 'Linux servers';
|
||||
case 'macos':
|
||||
return 'macOS devices';
|
||||
case 'windows':
|
||||
return 'Windows servers';
|
||||
default:
|
||||
return 'Host agents';
|
||||
}
|
||||
};
|
||||
|
||||
const cardDescription = () => {
|
||||
switch (variant) {
|
||||
case 'linux':
|
||||
return 'Install the Pulse host agent on Debian, Ubuntu, RHEL, Arch, or other Linux hosts to surface uptime and capacity metrics.';
|
||||
case 'macos':
|
||||
return 'Deploy the lightweight host agent via launchd to keep macOS hardware in view alongside your Proxmox estate.';
|
||||
case 'windows':
|
||||
return 'Track Windows Server hosts through a native Pulse agent. A first-party build is on the roadmap—compile from source today or watch this space.';
|
||||
default:
|
||||
return 'Install the Pulse host agent on Linux, macOS, or Windows servers to surface uptime, OS metadata, and capacity metrics.';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<SectionHeader title={cardTitle()} description={cardDescription()} />
|
||||
|
||||
<Card padding="lg" class="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">API token</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Manage tokens via <strong>Settings → Security</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={apiToken()}
|
||||
onInput={(e) => setApiToken(e.currentTarget.value.trim())}
|
||||
placeholder="Paste API token (leave blank to keep <api-token> placeholder)"
|
||||
class="flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateToken}
|
||||
disabled={isGeneratingToken()}
|
||||
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
|
||||
</button>
|
||||
<Show when={apiToken()}>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Token will be embedded in the commands below.
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Tokens generated here automatically include the host agent reporting scope (<code>host-agent:report</code>).
|
||||
</p>
|
||||
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{installMeta.title}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{installMeta.description}</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={installMeta.snippets}>
|
||||
{(snippet) => (
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h4>
|
||||
<CopyButton
|
||||
text={snippet.command.replace(
|
||||
TOKEN_PLACEHOLDER,
|
||||
apiToken() || TOKEN_PLACEHOLDER,
|
||||
)}
|
||||
>
|
||||
Copy command
|
||||
</CopyButton>
|
||||
</div>
|
||||
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
|
||||
<code>
|
||||
{snippet.command.replace(
|
||||
TOKEN_PLACEHOLDER,
|
||||
apiToken() || TOKEN_PLACEHOLDER,
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
<Show when={snippet.note}>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Reporting hosts</h3>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{hosts().length} connected</span>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={hosts().length > 0}
|
||||
fallback={
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{variant === 'windows'
|
||||
? 'No Windows hosts have reported yet. Compile the agent from source or check back when native artefacts are published.'
|
||||
: 'No host agents are reporting yet. Deploy the binary using the commands above to see hosts listed here.'}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/40">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Hostname</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Platform</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Uptime</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Memory</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Last seen</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<For each={hosts()}>
|
||||
{(host) => (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/60">
|
||||
<td class="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
{host.displayName || host.hostname || host.id}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300 capitalize">
|
||||
{host.platform || '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
||||
{host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
||||
{host.memory?.total
|
||||
? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
||||
{host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{renderTags(host)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostAgents;
|
||||
|
|
@ -5,10 +5,11 @@ import { useWebSocket } from '@/App';
|
|||
import { showSuccess, showError } from '@/utils/toast';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { NodeModal } from './NodeModal';
|
||||
import { APITokenManager } from './APITokenManager';
|
||||
import { ChangePasswordModal } from './ChangePasswordModal';
|
||||
import { GuestURLs } from './GuestURLs';
|
||||
import { DockerAgents } from './DockerAgents';
|
||||
import { HostAgents } from './HostAgents';
|
||||
import APITokenManager from './APITokenManager';
|
||||
import { OIDCPanel } from './OIDCPanel';
|
||||
import { QuickSecuritySetup } from './QuickSecuritySetup';
|
||||
import { SecurityPostureSummary } from './SecurityPostureSummary';
|
||||
|
|
@ -23,12 +24,17 @@ import { formField, labelClass, controlClass, formHelpText } from '@/components/
|
|||
import Server from 'lucide-solid/icons/server';
|
||||
import HardDrive from 'lucide-solid/icons/hard-drive';
|
||||
import Mail from 'lucide-solid/icons/mail';
|
||||
import Link from 'lucide-solid/icons/link';
|
||||
import Container from 'lucide-solid/icons/container';
|
||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||
import Shield from 'lucide-solid/icons/shield';
|
||||
import Activity from 'lucide-solid/icons/activity';
|
||||
import Loader from 'lucide-solid/icons/loader';
|
||||
import Boxes from 'lucide-solid/icons/boxes';
|
||||
import Network from 'lucide-solid/icons/network';
|
||||
import Terminal from 'lucide-solid/icons/terminal';
|
||||
import Monitor from 'lucide-solid/icons/monitor';
|
||||
import Laptop from 'lucide-solid/icons/laptop';
|
||||
import { ApiIcon } from '@/components/icons/ApiIcon';
|
||||
import type { NodeConfig } from '@/types/nodes';
|
||||
import type { UpdateInfo, VersionInfo } from '@/api/updates';
|
||||
import type { APITokenRecord } from '@/api/security';
|
||||
|
|
@ -222,8 +228,13 @@ type SettingsTab =
|
|||
| 'pbs'
|
||||
| 'pmg'
|
||||
| 'docker'
|
||||
| 'podman'
|
||||
| 'kubernetes'
|
||||
| 'linuxServers'
|
||||
| 'windowsServers'
|
||||
| 'macServers'
|
||||
| 'system'
|
||||
| 'urls'
|
||||
| 'api'
|
||||
| 'security'
|
||||
| 'diagnostics'
|
||||
| 'updates';
|
||||
|
|
@ -245,13 +256,33 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
|
|||
title: 'Docker Agents',
|
||||
description: 'Configure Docker hosts, agent tokens, and polling behaviour for container insights.',
|
||||
},
|
||||
podman: {
|
||||
title: 'Podman (container runtime)',
|
||||
description: 'Auto-discovery and agent-based monitoring for Podman hosts is on the roadmap.',
|
||||
},
|
||||
kubernetes: {
|
||||
title: 'Kubernetes',
|
||||
description: 'Cluster-wide monitoring via Pulse is coming soon. Watch this space for Helm charts and operators.',
|
||||
},
|
||||
linuxServers: {
|
||||
title: 'Linux Servers',
|
||||
description: 'Install the host agent on Debian, Ubuntu, RHEL, or other Linux distributions to surface capacity metrics.',
|
||||
},
|
||||
windowsServers: {
|
||||
title: 'Windows Servers',
|
||||
description: 'Native Windows support is planned; compile from source or track development updates here.',
|
||||
},
|
||||
macServers: {
|
||||
title: 'macOS Devices',
|
||||
description: 'Monitor macOS hosts via launchd-backed agents for uptime and temperature insights.',
|
||||
},
|
||||
system: {
|
||||
title: 'System Settings',
|
||||
description: 'Adjust Pulse core behaviour, discovery preferences, and software update cadence.',
|
||||
},
|
||||
urls: {
|
||||
title: 'Guest URLs',
|
||||
description: 'Define per-guest deep links to console, dashboards, or third-party tooling.',
|
||||
api: {
|
||||
title: 'API access',
|
||||
description: 'Generate scoped tokens and manage automation credentials for agents and integrations.',
|
||||
},
|
||||
security: {
|
||||
title: 'Security & Access',
|
||||
|
|
@ -300,10 +331,15 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
if (path.includes('/settings/pbs')) return 'pbs';
|
||||
if (path.includes('/settings/pmg')) return 'pmg';
|
||||
if (path.includes('/settings/docker')) return 'docker';
|
||||
if (path.includes('/settings/podman')) return 'podman';
|
||||
if (path.includes('/settings/kubernetes')) return 'kubernetes';
|
||||
if (path.includes('/settings/linuxServers')) return 'linuxServers';
|
||||
if (path.includes('/settings/windowsServers')) return 'windowsServers';
|
||||
if (path.includes('/settings/macServers')) return 'macServers';
|
||||
if (path.includes('/settings/system')) return 'system';
|
||||
if (path.includes('/settings/api')) return 'api';
|
||||
if (path.includes('/settings/security')) return 'security';
|
||||
if (path.includes('/settings/diagnostics')) return 'diagnostics';
|
||||
if (path.includes('/settings/urls')) return 'urls';
|
||||
if (path.includes('/settings/updates')) return 'updates';
|
||||
return 'pve';
|
||||
};
|
||||
|
|
@ -312,12 +348,13 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const activeTab = () => currentTab();
|
||||
|
||||
const setActiveTab = (tab: SettingsTab) => {
|
||||
if (currentTab() !== tab) {
|
||||
setCurrentTab(tab);
|
||||
}
|
||||
const targetPath = `/settings/${tab}`;
|
||||
if (location.pathname !== targetPath) {
|
||||
navigate(targetPath);
|
||||
return;
|
||||
}
|
||||
if (currentTab() !== tab) {
|
||||
setCurrentTab(tab);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -693,30 +730,31 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
};
|
||||
|
||||
const tabGroups: {
|
||||
id: 'proxmox' | 'docker' | 'administration';
|
||||
id: 'platforms' | 'administration';
|
||||
label: string;
|
||||
items: { id: SettingsTab; label: string; icon: JSX.Element }[];
|
||||
items: { id: SettingsTab; label: string; icon: JSX.Element; disabled?: boolean }[];
|
||||
}[] = [
|
||||
{
|
||||
id: 'proxmox',
|
||||
label: 'Proxmox',
|
||||
id: 'platforms',
|
||||
label: 'Platforms',
|
||||
items: [
|
||||
{ id: 'pve', label: 'Proxmox VE nodes', icon: <Server class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'pbs', label: 'Proxmox Backup Server', icon: <HardDrive class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'pmg', label: 'Proxmox Mail Gateway', icon: <Mail class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'urls', label: 'Guest URLs', icon: <Link class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'docker', label: 'Docker hosts', icon: <Container class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'podman', label: 'Podman hosts', icon: <Boxes class="w-4 h-4" strokeWidth={2} />, disabled: true },
|
||||
{ id: 'kubernetes', label: 'Kubernetes', icon: <Network class="w-4 h-4" strokeWidth={2} />, disabled: true },
|
||||
{ id: 'linuxServers', label: 'Linux servers', icon: <Terminal class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'windowsServers', label: 'Windows servers', icon: <Monitor class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'macServers', label: 'macOS devices', icon: <Laptop class="w-4 h-4" strokeWidth={2} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'docker',
|
||||
label: 'Docker',
|
||||
items: [{ id: 'docker', label: 'Docker hosts', icon: <Container class="w-4 h-4" strokeWidth={2} /> }],
|
||||
},
|
||||
{
|
||||
id: 'administration',
|
||||
label: 'Administration',
|
||||
items: [
|
||||
{ id: 'system', label: 'System', icon: <SettingsIcon class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'api', label: 'API access', icon: <ApiIcon class="w-4 h-4" /> },
|
||||
{ id: 'security', label: 'Security', icon: <Shield class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'diagnostics', label: 'Diagnostics', icon: <Activity class="w-4 h-4" strokeWidth={2} /> },
|
||||
],
|
||||
|
|
@ -1802,7 +1840,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<Card padding="none" class="relative lg:flex">
|
||||
<Card padding="none" class="relative lg:flex overflow-hidden">
|
||||
<div
|
||||
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-300`}
|
||||
onMouseEnter={() => setSidebarCollapsed(false)}
|
||||
|
|
@ -1822,16 +1860,26 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</Show>
|
||||
<div class="space-y-1.5">
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
{(item) => {
|
||||
const isActive = () => activeTab() === item.id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={activeTab() === item.id ? 'page' : undefined}
|
||||
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${
|
||||
activeTab() === item.id
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
|
||||
aria-current={isActive() ? 'page' : undefined}
|
||||
disabled={item.disabled}
|
||||
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${
|
||||
sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'
|
||||
} text-sm font-medium transition-colors ${
|
||||
item.disabled
|
||||
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600'
|
||||
: isActive()
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
|
||||
}`}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
setActiveTab(item.id);
|
||||
}}
|
||||
title={sidebarCollapsed() ? item.label : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
|
|
@ -1839,7 +1887,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<span class="truncate">{item.label}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1849,7 +1898,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<Show when={flatTabs.length > 0}>
|
||||
<div class="lg:hidden border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="p-1">
|
||||
|
|
@ -1858,26 +1907,36 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
style="-webkit-overflow-scrolling: touch;"
|
||||
>
|
||||
<For each={flatTabs}>
|
||||
{(tab) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
|
||||
activeTab() === tab.id
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)}
|
||||
{(tab) => {
|
||||
const isActive = activeTab() === tab.id;
|
||||
const disabled = tab.disabled;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
|
||||
disabled
|
||||
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600'
|
||||
: isActive
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setActiveTab(tab.id);
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="p-3 sm:p-6">
|
||||
<div class="p-3 sm:p-6 overflow-x-auto">
|
||||
{/* PVE Nodes Tab */}
|
||||
<Show when={activeTab() === 'pve'}>
|
||||
<div class="space-y-4">
|
||||
|
|
@ -2394,6 +2453,13 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<GuestURLs
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
setHasUnsavedChanges={setHasUnsavedChanges}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -3214,6 +3280,37 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<DockerAgents />
|
||||
</Show>
|
||||
|
||||
{/* Podman Tab */}
|
||||
<Show when={activeTab() === 'podman'}>
|
||||
<PlatformComingSoon
|
||||
name="Podman"
|
||||
description="Pulse will support Podman hosts via the same lightweight agent workflow as Docker. Keep an eye on the release notes for availability."
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Kubernetes Tab */}
|
||||
<Show when={activeTab() === 'kubernetes'}>
|
||||
<PlatformComingSoon
|
||||
name="Kubernetes"
|
||||
description="Native Kubernetes monitoring (agents, Helm chart, RBAC templates) is in design. Join the GitHub discussions to weigh in."
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Linux Host Agents */}
|
||||
<Show when={activeTab() === 'linuxServers'}>
|
||||
<HostAgents variant="linux" />
|
||||
</Show>
|
||||
|
||||
{/* Windows Host Agents */}
|
||||
<Show when={activeTab() === 'windowsServers'}>
|
||||
<HostAgents variant="windows" />
|
||||
</Show>
|
||||
|
||||
{/* macOS Host Agents */}
|
||||
<Show when={activeTab() === 'macServers'}>
|
||||
<HostAgents variant="macos" />
|
||||
</Show>
|
||||
|
||||
{/* System Settings Tab */}
|
||||
<Show when={activeTab() === 'system'}>
|
||||
<div class="space-y-6">
|
||||
|
|
@ -4273,6 +4370,36 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
{/* API Access */}
|
||||
<Show when={activeTab() === 'api'}>
|
||||
<div class="space-y-6">
|
||||
<SectionHeader title="API access" size="md" class="mb-2" />
|
||||
|
||||
<Card tone="muted" padding="lg" class="space-y-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Generate scoped tokens for Docker agents, host agents, and automation pipelines.
|
||||
Tokens are shown once—store them securely and rotate when infrastructure changes.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex w-fit items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200"
|
||||
>
|
||||
View scope reference
|
||||
</a>
|
||||
</Card>
|
||||
|
||||
<APITokenManager
|
||||
currentTokenHint={securityStatus()?.apiTokenHint}
|
||||
onTokensChanged={() => {
|
||||
void loadSecurityStatus();
|
||||
}}
|
||||
refreshing={securityStatusLoading()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Security Tab */}
|
||||
<Show when={activeTab() === 'security'}>
|
||||
<div class="space-y-6">
|
||||
|
|
@ -4610,14 +4737,18 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6">
|
||||
<APITokenManager
|
||||
currentTokenHint={securityStatus()?.apiTokenHint}
|
||||
onTokensChanged={() => {
|
||||
void loadSecurityStatus();
|
||||
}}
|
||||
refreshing={securityStatusLoading()}
|
||||
/>
|
||||
<div class="p-6 space-y-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
API tokens now live under the dedicated API workspace. Generate new scoped tokens,
|
||||
review existing access, and rotate credentials from the API menu.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('api')}
|
||||
class="inline-flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200"
|
||||
>
|
||||
Open API tab
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
@ -6189,13 +6320,6 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Guest URLs Tab */}
|
||||
<Show when={activeTab() === 'urls'}>
|
||||
<GuestURLs
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
setHasUnsavedChanges={setHasUnsavedChanges}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -6737,4 +6861,22 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
const PlatformComingSoon: Component<{ name: string; description?: string }> = (props) => {
|
||||
const description =
|
||||
props.description ??
|
||||
`Support for ${props.name} is on the roadmap. Track progress on GitHub or join our community discussions to weigh in on requirements.`;
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<SectionHeader title={`${props.name} integration`} description={description} />
|
||||
<Card padding="lg">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
We’re collecting feedback and prioritising the engineering work for this platform. If you’d like to influence
|
||||
the roadmap or volunteer for early testing, please open a discussion on GitHub or reach out via Discord.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
|
|
|||
27
frontend-modern/src/components/icons/ApiIcon.tsx
Normal file
27
frontend-modern/src/components/icons/ApiIcon.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { Component } from 'solid-js';
|
||||
|
||||
interface ApiIconProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const ApiIcon: Component<ApiIconProps> = (props) => (
|
||||
<svg
|
||||
class={props.class}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 7v10" />
|
||||
<path d="M20 7v10" />
|
||||
<path d="M9 12h6" />
|
||||
<path d="M12 9v6" />
|
||||
<rect x="2.5" y="5" width="19" height="14" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ApiIcon;
|
||||
21
frontend-modern/src/components/icons/ServersIcon.tsx
Normal file
21
frontend-modern/src/components/icons/ServersIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Component } from 'solid-js';
|
||||
|
||||
interface ServersIconProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const ServersIcon: Component<ServersIconProps> = (props) => (
|
||||
<svg
|
||||
class={props.class}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.5 4.125C4.5 3.504 5.004 3 5.625 3h12.75c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125H5.625A1.125 1.125 0 0 1 4.5 7.875v-3.75Zm1.875.75a.375.375 0 0 0-.375.375v1.125c0 .207.168.375.375.375h2.25a.375.375 0 0 0 .375-.375V5.25a.375.375 0 0 0-.375-.375h-2.25Zm5.625.375a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm3 0a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm-8.25 5.25c0-.621.504-1.125 1.125-1.125h12.75c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125H7.875A1.125 1.125 0 0 1 6.75 14.25v-3.75Zm1.875.75a.375.375 0 0 0-.375.375v1.125c0 .207.168.375.375.375h2.25a.375.375 0 0 0 .375-.375V11.25a.375.375 0 0 0-.375-.375h-2.25Zm5.625.375a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm3 0a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0ZM6.75 18.375c0-.621.504-1.125 1.125-1.125h12.75c.621 0 1.125.504 1.125 1.125v1.5A1.125 1.125 0 0 1 20.625 21H7.875a1.125 1.125 0 0 1-1.125-1.125v-1.5Zm1.5.375a.375.375 0 0 0-.375.375v.375c0 .207.168.375.375.375h1.5a.375.375 0 0 0 .375-.375v-.375a.375.375 0 0 0-.375-.375h-1.5Zm4.875.375a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm3 0a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
64
frontend-modern/src/constants/apiScopes.ts
Normal file
64
frontend-modern/src/constants/apiScopes.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
export interface APIScopeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
group: 'Monitoring' | 'Agents' | 'Settings';
|
||||
}
|
||||
|
||||
export const HOST_AGENT_SCOPE = 'host-agent:report';
|
||||
export const DOCKER_REPORT_SCOPE = 'docker:report';
|
||||
export const DOCKER_MANAGE_SCOPE = 'docker:manage';
|
||||
export const MONITORING_READ_SCOPE = 'monitoring:read';
|
||||
export const MONITORING_WRITE_SCOPE = 'monitoring:write';
|
||||
export const SETTINGS_READ_SCOPE = 'settings:read';
|
||||
export const SETTINGS_WRITE_SCOPE = 'settings:write';
|
||||
|
||||
export const API_SCOPE_OPTIONS: APIScopeOption[] = [
|
||||
{
|
||||
value: MONITORING_READ_SCOPE,
|
||||
label: 'Dashboards & alerts (read)',
|
||||
description: 'View monitoring data, dashboards, and alert history.',
|
||||
group: 'Monitoring',
|
||||
},
|
||||
{
|
||||
value: MONITORING_WRITE_SCOPE,
|
||||
label: 'Alert actions (write)',
|
||||
description: 'Acknowledge, silence, and clear alerts.',
|
||||
group: 'Monitoring',
|
||||
},
|
||||
{
|
||||
value: DOCKER_REPORT_SCOPE,
|
||||
label: 'Docker agent reporting',
|
||||
description: 'Allow the Docker agent to submit host and container telemetry.',
|
||||
group: 'Agents',
|
||||
},
|
||||
{
|
||||
value: DOCKER_MANAGE_SCOPE,
|
||||
label: 'Docker lifecycle management',
|
||||
description: 'Enable agent-triggered container commands and host actions.',
|
||||
group: 'Agents',
|
||||
},
|
||||
{
|
||||
value: HOST_AGENT_SCOPE,
|
||||
label: 'Host agent reporting',
|
||||
description: 'Allow the host agent to send OS, CPU, and disk metrics.',
|
||||
group: 'Agents',
|
||||
},
|
||||
{
|
||||
value: SETTINGS_READ_SCOPE,
|
||||
label: 'Settings (read)',
|
||||
description: 'Fetch configuration snapshots such as nodes and security posture.',
|
||||
group: 'Settings',
|
||||
},
|
||||
{
|
||||
value: SETTINGS_WRITE_SCOPE,
|
||||
label: 'Settings (write)',
|
||||
description: 'Modify configuration, manage tokens, and trigger updates.',
|
||||
group: 'Settings',
|
||||
},
|
||||
];
|
||||
|
||||
export const API_SCOPE_LABELS = API_SCOPE_OPTIONS.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.value] = option.label;
|
||||
return acc;
|
||||
}, {});
|
||||
|
|
@ -194,6 +194,49 @@ export interface DockerContainerNetwork {
|
|||
ipv6?: string;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
id: string;
|
||||
hostname: string;
|
||||
displayName: string;
|
||||
platform?: string;
|
||||
osName?: string;
|
||||
osVersion?: string;
|
||||
kernelVersion?: string;
|
||||
architecture?: string;
|
||||
cpuCount?: number;
|
||||
cpuUsage?: number;
|
||||
loadAverage?: number[];
|
||||
memory: Memory;
|
||||
disks?: Disk[];
|
||||
networkInterfaces?: HostNetworkInterface[];
|
||||
sensors?: HostSensorSummary;
|
||||
status: string;
|
||||
uptimeSeconds?: number;
|
||||
lastSeen: number;
|
||||
intervalSeconds?: number;
|
||||
agentVersion?: string;
|
||||
tokenId?: string;
|
||||
tokenName?: string;
|
||||
tokenHint?: string;
|
||||
tokenLastUsedAt?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface HostNetworkInterface {
|
||||
name: string;
|
||||
mac?: string;
|
||||
addresses?: string[];
|
||||
rxBytes?: number;
|
||||
txBytes?: number;
|
||||
speedMbps?: number;
|
||||
}
|
||||
|
||||
export interface HostSensorSummary {
|
||||
temperatureCelsius?: Record<string, number>;
|
||||
fanRpm?: Record<string, number>;
|
||||
additional?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ReplicationJob {
|
||||
id: string;
|
||||
instance: string;
|
||||
|
|
|
|||
12
go.mod
12
go.mod
|
|
@ -12,11 +12,14 @@ require (
|
|||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/spf13/cobra v1.9.1
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/oauth2 v0.31.0
|
||||
golang.org/x/sys v0.36.0
|
||||
golang.org/x/term v0.35.0
|
||||
golang.org/x/time v0.13.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
|
@ -36,7 +39,9 @@ require (
|
|||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
|
|
@ -46,11 +51,15 @@ require (
|
|||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
|
|
@ -58,7 +67,6 @@ require (
|
|||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
|
|
|
|||
BIN
go.sum
BIN
go.sum
Binary file not shown.
|
|
@ -9,6 +9,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
|
|
@ -681,26 +682,59 @@ func (h *AlertHandlers) HandleAlerts(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
switch {
|
||||
case path == "config" && r.Method == http.MethodGet:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringRead) {
|
||||
return
|
||||
}
|
||||
h.GetAlertConfig(w, r)
|
||||
case path == "config" && r.Method == http.MethodPut:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
|
||||
return
|
||||
}
|
||||
h.UpdateAlertConfig(w, r)
|
||||
case path == "activate" && r.Method == http.MethodPost:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
|
||||
return
|
||||
}
|
||||
h.ActivateAlerts(w, r)
|
||||
case path == "active" && r.Method == http.MethodGet:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringRead) {
|
||||
return
|
||||
}
|
||||
h.GetActiveAlerts(w, r)
|
||||
case path == "history" && r.Method == http.MethodGet:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringRead) {
|
||||
return
|
||||
}
|
||||
h.GetAlertHistory(w, r)
|
||||
case path == "history" && r.Method == http.MethodDelete:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
|
||||
return
|
||||
}
|
||||
h.ClearAlertHistory(w, r)
|
||||
case path == "bulk/acknowledge" && r.Method == http.MethodPost:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
|
||||
return
|
||||
}
|
||||
h.BulkAcknowledgeAlerts(w, r)
|
||||
case path == "bulk/clear" && r.Method == http.MethodPost:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
|
||||
return
|
||||
}
|
||||
h.BulkClearAlerts(w, r)
|
||||
case strings.HasSuffix(path, "/acknowledge") && r.Method == http.MethodPost:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
|
||||
return
|
||||
}
|
||||
h.AcknowledgeAlert(w, r)
|
||||
case strings.HasSuffix(path, "/unacknowledge") && r.Method == http.MethodPost:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
|
||||
return
|
||||
}
|
||||
h.UnacknowledgeAlert(w, r)
|
||||
case strings.HasSuffix(path, "/clear") && r.Method == http.MethodPost:
|
||||
if !ensureScope(w, r, config.ScopeMonitoringWrite) {
|
||||
return
|
||||
}
|
||||
h.ClearAlert(w, r)
|
||||
default:
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
cryptorand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -618,6 +619,63 @@ func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc
|
|||
}
|
||||
}
|
||||
|
||||
// RequireScope ensures that token-authenticated requests include the specified scope.
|
||||
// Session-based (browser) requests bypass the scope check.
|
||||
func RequireScope(scope string, handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if scope == "" {
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
record := getAPITokenRecordFromRequest(r)
|
||||
if record == nil {
|
||||
// Session-authenticated request
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if record.HasScope(scope) {
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().
|
||||
Str("token_id", record.ID).
|
||||
Str("required_scope", scope).
|
||||
Msg("API token missing required scope")
|
||||
|
||||
respondMissingScope(w, scope)
|
||||
}
|
||||
}
|
||||
|
||||
func respondMissingScope(w http.ResponseWriter, scope string) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": "missing_scope",
|
||||
"requiredScope": scope,
|
||||
})
|
||||
}
|
||||
|
||||
// ensureScope enforces that the request either originates from a session or a token
|
||||
// possessing the specified scope. Returns true when access should continue.
|
||||
func ensureScope(w http.ResponseWriter, r *http.Request, scope string) bool {
|
||||
if scope == "" {
|
||||
return true
|
||||
}
|
||||
record := getAPITokenRecordFromRequest(r)
|
||||
if record == nil || record.HasScope(scope) {
|
||||
return true
|
||||
}
|
||||
respondMissingScope(w, scope)
|
||||
return false
|
||||
}
|
||||
|
||||
func attachAPITokenRecord(r *http.Request, record *config.APITokenRecord) {
|
||||
if record == nil {
|
||||
return
|
||||
|
|
|
|||
58
internal/api/auth_scope_test.go
Normal file
58
internal/api/auth_scope_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
func TestRequireScopeAllowsSession(t *testing.T) {
|
||||
handler := RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200 for session request, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireScopeRejectsMissingScope(t *testing.T) {
|
||||
handler := RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
record := config.APITokenRecord{ID: "token-1", Scopes: []string{config.ScopeMonitoringRead}}
|
||||
attachAPITokenRecord(req, &record)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403 when scope missing, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireScopeAllowsMatchingScope(t *testing.T) {
|
||||
handler := RequireScope(config.ScopeDockerReport, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
record := config.APITokenRecord{ID: "token-2", Scopes: []string{config.ScopeDockerReport}}
|
||||
attachAPITokenRecord(req, &record)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected status 202 when scope present, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
78
internal/api/host_agents.go
Normal file
78
internal/api/host_agents.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// HostAgentHandlers manages ingest from the pulse-host-agent.
|
||||
type HostAgentHandlers struct {
|
||||
monitor *monitoring.Monitor
|
||||
wsHub *websocket.Hub
|
||||
}
|
||||
|
||||
// NewHostAgentHandlers constructs a new handler set for host agents.
|
||||
func NewHostAgentHandlers(m *monitoring.Monitor, hub *websocket.Hub) *HostAgentHandlers {
|
||||
return &HostAgentHandlers{monitor: m, wsHub: hub}
|
||||
}
|
||||
|
||||
// SetMonitor updates the monitor reference for host agent handlers.
|
||||
func (h *HostAgentHandlers) SetMonitor(m *monitoring.Monitor) {
|
||||
h.monitor = m
|
||||
}
|
||||
|
||||
// HandleReport ingests host agent reports.
|
||||
func (h *HostAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var report agentshost.Report
|
||||
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if report.Timestamp.IsZero() {
|
||||
report.Timestamp = time.Now().UTC()
|
||||
}
|
||||
|
||||
tokenRecord := getAPITokenRecordFromRequest(r)
|
||||
|
||||
host, err := h.monitor.ApplyHostReport(report, tokenRecord)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("hostId", host.ID).
|
||||
Str("hostname", host.Hostname).
|
||||
Str("platform", host.Platform).
|
||||
Msg("Host agent report processed")
|
||||
|
||||
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"hostId": host.ID,
|
||||
"lastSeen": host.LastSeen,
|
||||
"platform": host.Platform,
|
||||
"osName": host.OSName,
|
||||
"osVersion": host.OSVersion,
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, resp); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize host agent response")
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ type Router struct {
|
|||
configHandlers *ConfigHandlers
|
||||
notificationHandlers *NotificationHandlers
|
||||
dockerAgentHandlers *DockerAgentHandlers
|
||||
hostAgentHandlers *HostAgentHandlers
|
||||
systemSettingsHandler *SystemSettingsHandler
|
||||
wsHub *websocket.Hub
|
||||
reloadFunc func() error
|
||||
|
|
@ -120,21 +121,23 @@ func (r *Router) setupRoutes() {
|
|||
r.configHandlers = NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings)
|
||||
updateHandlers := NewUpdateHandlers(r.updateManager, r.config.DataPath)
|
||||
r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub)
|
||||
r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub)
|
||||
|
||||
// API routes
|
||||
r.mux.HandleFunc("/api/health", r.handleHealth)
|
||||
r.mux.HandleFunc("/api/monitoring/scheduler/health", RequireAuth(r.config, r.handleSchedulerHealth))
|
||||
r.mux.HandleFunc("/api/state", r.handleState)
|
||||
r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, r.dockerAgentHandlers.HandleReport))
|
||||
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, r.dockerAgentHandlers.HandleCommandAck))
|
||||
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, r.dockerAgentHandlers.HandleDockerHostActions))
|
||||
r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleReport)))
|
||||
r.mux.HandleFunc("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleReport)))
|
||||
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleCommandAck)))
|
||||
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
|
||||
r.mux.HandleFunc("/api/version", r.handleVersion)
|
||||
r.mux.HandleFunc("/api/storage/", r.handleStorage)
|
||||
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
|
||||
r.mux.HandleFunc("/api/charts", r.handleCharts)
|
||||
r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics))
|
||||
r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, r.handleDiagnosticsRegisterProxyNodes))
|
||||
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, r.handleDiagnosticsDockerPrepareToken))
|
||||
r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsRegisterProxyNodes)))
|
||||
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken)))
|
||||
r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy)
|
||||
r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript)
|
||||
r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript)
|
||||
|
|
@ -173,9 +176,9 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/config/nodes", func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
r.configHandlers.HandleGetNodes(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsRead, r.configHandlers.HandleGetNodes))(w, req)
|
||||
case http.MethodPost:
|
||||
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleAddNode)(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleAddNode))(w, req)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
|
@ -184,7 +187,7 @@ func (r *Router) setupRoutes() {
|
|||
// Test node configuration endpoint (for new nodes)
|
||||
r.mux.HandleFunc("/api/config/nodes/test-config", func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodPost {
|
||||
r.configHandlers.HandleTestNodeConfig(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleTestNodeConfig))(w, req)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
|
@ -193,7 +196,7 @@ func (r *Router) setupRoutes() {
|
|||
// Test connection endpoint
|
||||
r.mux.HandleFunc("/api/config/nodes/test-connection", func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodPost {
|
||||
r.configHandlers.HandleTestConnection(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleTestConnection))(w, req)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
|
@ -201,13 +204,13 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/config/nodes/", func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodPut:
|
||||
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleUpdateNode)(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleUpdateNode))(w, req)
|
||||
case http.MethodDelete:
|
||||
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleDeleteNode)(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleDeleteNode))(w, req)
|
||||
case http.MethodPost:
|
||||
// Handle test endpoint
|
||||
if strings.HasSuffix(req.URL.Path, "/test") {
|
||||
r.configHandlers.HandleTestNode(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleTestNode))(w, req)
|
||||
} else {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
|
|
@ -220,10 +223,10 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/config/system", func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
r.configHandlers.HandleGetSystemSettings(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsRead, r.configHandlers.HandleGetSystemSettings))(w, req)
|
||||
case http.MethodPut:
|
||||
// DEPRECATED - use /api/system/settings/update instead
|
||||
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleUpdateSystemSettingsOLD)(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleUpdateSystemSettingsOLD))(w, req)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
|
@ -233,9 +236,9 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/system/mock-mode", func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
r.configHandlers.HandleGetMockMode(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsRead, r.configHandlers.HandleGetMockMode))(w, req)
|
||||
case http.MethodPost, http.MethodPut:
|
||||
RequireAdmin(r.configHandlers.config, r.configHandlers.HandleUpdateMockMode)(w, req)
|
||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleUpdateMockMode))(w, req)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
|
@ -248,20 +251,31 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/logout", r.handleLogout)
|
||||
r.mux.HandleFunc("/api/login", r.handleLogin)
|
||||
r.mux.HandleFunc("/api/security/reset-lockout", r.handleResetLockout)
|
||||
r.mux.HandleFunc("/api/security/oidc", RequireAdmin(r.config, r.handleOIDCConfig))
|
||||
r.mux.HandleFunc("/api/security/oidc", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleOIDCConfig)))
|
||||
r.mux.HandleFunc("/api/oidc/login", r.handleOIDCLogin)
|
||||
r.mux.HandleFunc(config.DefaultOIDCCallbackPath, r.handleOIDCCallback)
|
||||
r.mux.HandleFunc("/api/security/tokens", RequireAdmin(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
if !ensureScope(w, req, config.ScopeSettingsRead) {
|
||||
return
|
||||
}
|
||||
r.handleListAPITokens(w, req)
|
||||
case http.MethodPost:
|
||||
if !ensureScope(w, req, config.ScopeSettingsWrite) {
|
||||
return
|
||||
}
|
||||
r.handleCreateAPIToken(w, req)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
r.mux.HandleFunc("/api/security/tokens/", RequireAdmin(r.config, r.handleDeleteAPIToken))
|
||||
r.mux.HandleFunc("/api/security/tokens/", RequireAdmin(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
if !ensureScope(w, req, config.ScopeSettingsWrite) {
|
||||
return
|
||||
}
|
||||
r.handleDeleteAPIToken(w, req)
|
||||
}))
|
||||
r.mux.HandleFunc("/api/security/status", func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -847,13 +861,13 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/notifications/", r.notificationHandlers.HandleNotifications)
|
||||
|
||||
// Settings routes
|
||||
r.mux.HandleFunc("/api/settings", getSettings)
|
||||
r.mux.HandleFunc("/api/settings/update", updateSettings)
|
||||
r.mux.HandleFunc("/api/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, getSettings)))
|
||||
r.mux.HandleFunc("/api/settings/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateSettings)))
|
||||
|
||||
// System settings and API token management
|
||||
r.systemSettingsHandler = NewSystemSettingsHandler(r.config, r.persistence, r.wsHub, r.monitor, r.reloadSystemSettings)
|
||||
r.mux.HandleFunc("/api/system/settings", r.systemSettingsHandler.HandleGetSystemSettings)
|
||||
r.mux.HandleFunc("/api/system/settings/update", r.systemSettingsHandler.HandleUpdateSystemSettings)
|
||||
r.mux.HandleFunc("/api/system/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.systemSettingsHandler.HandleGetSystemSettings)))
|
||||
r.mux.HandleFunc("/api/system/settings/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.systemSettingsHandler.HandleUpdateSystemSettings)))
|
||||
r.mux.HandleFunc("/api/system/ssh-config", r.handleSSHConfig)
|
||||
r.mux.HandleFunc("/api/system/verify-temperature-ssh", r.handleVerifyTemperatureSSH)
|
||||
r.mux.HandleFunc("/api/system/proxy-public-key", r.handleProxyPublicKey)
|
||||
|
|
@ -1040,6 +1054,9 @@ func (r *Router) SetMonitor(m *monitoring.Monitor) {
|
|||
if r.dockerAgentHandlers != nil {
|
||||
r.dockerAgentHandlers.SetMonitor(m)
|
||||
}
|
||||
if r.hostAgentHandlers != nil {
|
||||
r.hostAgentHandlers.SetMonitor(m)
|
||||
}
|
||||
if r.systemSettingsHandler != nil {
|
||||
r.systemSettingsHandler.SetMonitor(m)
|
||||
}
|
||||
|
|
@ -2231,6 +2248,11 @@ func (r *Router) handleState(w http.ResponseWriter, req *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if record := getAPITokenRecordFromRequest(req); record != nil && !record.HasScope(config.ScopeMonitoringRead) {
|
||||
respondMissingScope(w, config.ScopeMonitoringRead)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Msg("[DEBUG] handleState: Before GetState")
|
||||
state := r.monitor.GetState()
|
||||
log.Debug().Msg("[DEBUG] handleState: After GetState, before ToFrontend")
|
||||
|
|
@ -3257,7 +3279,7 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req
|
|||
return
|
||||
}
|
||||
|
||||
record, err := config.NewAPITokenRecord(rawToken, name)
|
||||
record, err := config.NewAPITokenRecord(rawToken, name, []string{config.ScopeDockerReport})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to construct token record for docker migration")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate API token", nil)
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ func TestAuthenticatedEndpointsRequireToken(t *testing.T) {
|
|||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.DisableAuth = false
|
||||
cfg.APITokenEnabled = true
|
||||
record, err := config.NewAPITokenRecord(apiToken, "Integration test token")
|
||||
record, err := config.NewAPITokenRecord(apiToken, "Integration test token", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create API token record: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
|||
// Store the raw API token for displaying to the user
|
||||
rawAPIToken := setupRequest.APIToken
|
||||
|
||||
tokenRecord, err := config.NewAPITokenRecord(rawAPIToken, "Primary token")
|
||||
tokenRecord, err := config.NewAPITokenRecord(rawAPIToken, "Primary token", nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to construct API token record")
|
||||
http.Error(w, "Failed to process API token", http.StatusInternalServerError)
|
||||
|
|
@ -156,14 +156,14 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
|||
}
|
||||
log.Info().Msg("Runtime config updated with new security settings - active immediately")
|
||||
|
||||
// Save system settings to system.json
|
||||
systemSettings := config.DefaultSystemSettings()
|
||||
systemSettings.ConnectionTimeout = 10 // Default seconds
|
||||
systemSettings.AutoUpdateEnabled = false // Default disabled
|
||||
if err := r.persistence.SaveSystemSettings(*systemSettings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save system settings")
|
||||
// Continue anyway - not critical for auth setup
|
||||
}
|
||||
// Save system settings to system.json
|
||||
systemSettings := config.DefaultSystemSettings()
|
||||
systemSettings.ConnectionTimeout = 10 // Default seconds
|
||||
systemSettings.AutoUpdateEnabled = false // Default disabled
|
||||
if err := r.persistence.SaveSystemSettings(*systemSettings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save system settings")
|
||||
// Continue anyway - not critical for auth setup
|
||||
}
|
||||
|
||||
// Detect environment
|
||||
isSystemd := os.Getenv("INVOCATION_ID") != ""
|
||||
|
|
@ -428,7 +428,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
tokenRecord, err := config.NewAPITokenRecord(rawToken, "Regenerated token")
|
||||
tokenRecord, err := config.NewAPITokenRecord(rawToken, "Regenerated token", nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to construct API token record")
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -19,6 +21,7 @@ type apiTokenDTO struct {
|
|||
Suffix string `json:"suffix"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func toAPITokenDTO(record config.APITokenRecord) apiTokenDTO {
|
||||
|
|
@ -29,9 +32,58 @@ func toAPITokenDTO(record config.APITokenRecord) apiTokenDTO {
|
|||
Suffix: record.Suffix,
|
||||
CreatedAt: record.CreatedAt,
|
||||
LastUsedAt: record.LastUsedAt,
|
||||
Scopes: append([]string{}, record.Scopes...),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRequestedScopes(raw *[]string) ([]string, error) {
|
||||
if raw == nil {
|
||||
return []string{config.ScopeWildcard}, nil
|
||||
}
|
||||
|
||||
requested := *raw
|
||||
if len(requested) == 0 {
|
||||
return nil, fmt.Errorf("select at least one scope or omit the field for full access")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(requested))
|
||||
normalized := make([]string, 0, len(requested))
|
||||
hasWildcard := false
|
||||
|
||||
for _, scope := range requested {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
return nil, fmt.Errorf("scope identifiers cannot be blank")
|
||||
}
|
||||
if scope == config.ScopeWildcard {
|
||||
hasWildcard = true
|
||||
continue
|
||||
}
|
||||
if !config.IsKnownScope(scope) {
|
||||
return nil, fmt.Errorf("unknown scope %q", scope)
|
||||
}
|
||||
if _, exists := seen[scope]; exists {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
normalized = append(normalized, scope)
|
||||
}
|
||||
|
||||
if hasWildcard {
|
||||
if len(normalized) > 0 {
|
||||
return nil, fmt.Errorf("wildcard '*' cannot be combined with other scopes")
|
||||
}
|
||||
return []string{config.ScopeWildcard}, nil
|
||||
}
|
||||
|
||||
if len(normalized) == 0 {
|
||||
return nil, fmt.Errorf("select at least one scope")
|
||||
}
|
||||
|
||||
sort.Strings(normalized)
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// handleListAPITokens returns all configured API tokens (metadata only).
|
||||
func (r *Router) handleListAPITokens(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
|
|
@ -51,7 +103,8 @@ func (r *Router) handleListAPITokens(w http.ResponseWriter, req *http.Request) {
|
|||
}
|
||||
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Scopes *[]string `json:"scopes"`
|
||||
}
|
||||
|
||||
// handleCreateAPIToken generates and stores a new API token.
|
||||
|
|
@ -73,6 +126,13 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request)
|
|||
name = "API token"
|
||||
}
|
||||
|
||||
scopes, err := normalizeRequestedScopes(payload.Scopes)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Invalid scopes provided for API token creation")
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rawToken, err := internalauth.GenerateAPIToken()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate API token")
|
||||
|
|
@ -80,7 +140,7 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
record, err := config.NewAPITokenRecord(rawToken, name)
|
||||
record, err := config.NewAPITokenRecord(rawToken, name, scopes)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("token_name", name).Msg("Failed to construct API token record")
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
|
|
|
|||
52
internal/api/security_tokens_test.go
Normal file
52
internal/api/security_tokens_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
func TestNormalizeRequestedScopesDefaultsToWildcard(t *testing.T) {
|
||||
scopes, err := normalizeRequestedScopes(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(scopes) != 1 || scopes[0] != config.ScopeWildcard {
|
||||
t.Fatalf("expected wildcard scope, got %#v", scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestedScopesValidList(t *testing.T) {
|
||||
raw := []string{"docker:report", "docker:report", "monitoring:read"}
|
||||
scopes, err := normalizeRequestedScopes(&raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(scopes) != 2 {
|
||||
t.Fatalf("expected 2 scopes, got %d", len(scopes))
|
||||
}
|
||||
if scopes[0] != config.ScopeDockerReport || scopes[1] != config.ScopeMonitoringRead {
|
||||
t.Fatalf("unexpected scopes order: %#v", scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestedScopesRejectsMixedWildcard(t *testing.T) {
|
||||
raw := []string{"*", "docker:report"}
|
||||
if _, err := normalizeRequestedScopes(&raw); err == nil {
|
||||
t.Fatal("expected error when mixing wildcard with explicit scopes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestedScopesRejectsUnknown(t *testing.T) {
|
||||
raw := []string{"unknown"}
|
||||
if _, err := normalizeRequestedScopes(&raw); err == nil {
|
||||
t.Fatal("expected error for unknown scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestedScopesRejectsEmpty(t *testing.T) {
|
||||
raw := []string{}
|
||||
if _, err := normalizeRequestedScopes(&raw); err == nil {
|
||||
t.Fatal("expected error for empty scopes array")
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ type StateResponse struct {
|
|||
VMs []models.VM `json:"vms"`
|
||||
Containers []models.Container `json:"containers"`
|
||||
DockerHosts []models.DockerHostFrontend `json:"dockerHosts"`
|
||||
Hosts []models.HostFrontend `json:"hosts"`
|
||||
Storage []models.Storage `json:"storage"`
|
||||
CephClusters []models.CephCluster `json:"cephClusters"`
|
||||
PBSInstances []models.PBSInstance `json:"pbs"`
|
||||
|
|
|
|||
|
|
@ -9,6 +9,37 @@ import (
|
|||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
)
|
||||
|
||||
// Canonical API token scope strings.
|
||||
const (
|
||||
ScopeWildcard = "*"
|
||||
ScopeMonitoringRead = "monitoring:read"
|
||||
ScopeMonitoringWrite = "monitoring:write"
|
||||
ScopeDockerReport = "docker:report"
|
||||
ScopeDockerManage = "docker:manage"
|
||||
ScopeHostReport = "host-agent:report"
|
||||
ScopeSettingsRead = "settings:read"
|
||||
ScopeSettingsWrite = "settings:write"
|
||||
)
|
||||
|
||||
// AllKnownScopes enumerates scopes recognized by the backend (excluding the wildcard sentinel).
|
||||
var AllKnownScopes = []string{
|
||||
ScopeMonitoringRead,
|
||||
ScopeMonitoringWrite,
|
||||
ScopeDockerReport,
|
||||
ScopeDockerManage,
|
||||
ScopeHostReport,
|
||||
ScopeSettingsRead,
|
||||
ScopeSettingsWrite,
|
||||
}
|
||||
|
||||
var scopeLookup = func() map[string]struct{} {
|
||||
lookup := make(map[string]struct{}, len(AllKnownScopes))
|
||||
for _, scope := range AllKnownScopes {
|
||||
lookup[scope] = struct{}{}
|
||||
}
|
||||
return lookup
|
||||
}()
|
||||
|
||||
// ErrInvalidToken is returned when a token value is empty or malformed.
|
||||
var ErrInvalidToken = errors.New("invalid API token")
|
||||
|
||||
|
|
@ -21,6 +52,20 @@ type APITokenRecord struct {
|
|||
Suffix string `json:"suffix,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
// ensureScopes normalizes the scope slice, applying legacy defaults.
|
||||
func (r *APITokenRecord) ensureScopes() {
|
||||
if len(r.Scopes) == 0 {
|
||||
r.Scopes = []string{ScopeWildcard}
|
||||
return
|
||||
}
|
||||
|
||||
// Copy to avoid shared underlying slice if this record is reused.
|
||||
scopes := make([]string, len(r.Scopes))
|
||||
copy(scopes, r.Scopes)
|
||||
r.Scopes = scopes
|
||||
}
|
||||
|
||||
// Clone returns a copy of the record with duplicated pointer fields.
|
||||
|
|
@ -30,11 +75,12 @@ func (r *APITokenRecord) Clone() APITokenRecord {
|
|||
t := *r.LastUsedAt
|
||||
clone.LastUsedAt = &t
|
||||
}
|
||||
clone.ensureScopes()
|
||||
return clone
|
||||
}
|
||||
|
||||
// NewAPITokenRecord constructs a metadata record from the provided raw token.
|
||||
func NewAPITokenRecord(rawToken, name string) (*APITokenRecord, error) {
|
||||
func NewAPITokenRecord(rawToken, name string, scopes []string) (*APITokenRecord, error) {
|
||||
if rawToken == "" {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
|
@ -47,12 +93,13 @@ func NewAPITokenRecord(rawToken, name string) (*APITokenRecord, error) {
|
|||
Prefix: tokenPrefix(rawToken),
|
||||
Suffix: tokenSuffix(rawToken),
|
||||
CreatedAt: now,
|
||||
Scopes: normalizeScopes(scopes),
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// NewHashedAPITokenRecord constructs a record from an already hashed token.
|
||||
func NewHashedAPITokenRecord(hashedToken, name string, createdAt time.Time) (*APITokenRecord, error) {
|
||||
func NewHashedAPITokenRecord(hashedToken, name string, createdAt time.Time, scopes []string) (*APITokenRecord, error) {
|
||||
if hashedToken == "" {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
|
@ -67,6 +114,7 @@ func NewHashedAPITokenRecord(hashedToken, name string, createdAt time.Time) (*AP
|
|||
Prefix: tokenPrefix(hashedToken),
|
||||
Suffix: tokenSuffix(hashedToken),
|
||||
CreatedAt: createdAt,
|
||||
Scopes: normalizeScopes(scopes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +198,7 @@ func (c *Config) ValidateAPIToken(rawToken string) (*APITokenRecord, bool) {
|
|||
if auth.CompareAPIToken(rawToken, record.Hash) {
|
||||
now := time.Now().UTC()
|
||||
c.APITokens[idx].LastUsedAt = &now
|
||||
c.APITokens[idx].ensureScopes()
|
||||
return &c.APITokens[idx], true
|
||||
}
|
||||
}
|
||||
|
|
@ -158,6 +207,7 @@ func (c *Config) ValidateAPIToken(rawToken string) (*APITokenRecord, bool) {
|
|||
|
||||
// UpsertAPIToken inserts or replaces a record by ID.
|
||||
func (c *Config) UpsertAPIToken(record APITokenRecord) {
|
||||
record.ensureScopes()
|
||||
for idx, existing := range c.APITokens {
|
||||
if existing.ID == record.ID {
|
||||
c.APITokens[idx] = record
|
||||
|
|
@ -182,6 +232,9 @@ func (c *Config) RemoveAPIToken(id string) bool {
|
|||
|
||||
// SortAPITokens keeps tokens ordered newest-first and syncs the legacy APIToken field.
|
||||
func (c *Config) SortAPITokens() {
|
||||
for i := range c.APITokens {
|
||||
c.APITokens[i].ensureScopes()
|
||||
}
|
||||
sort.SliceStable(c.APITokens, func(i, j int) bool {
|
||||
return c.APITokens[i].CreatedAt.After(c.APITokens[j].CreatedAt)
|
||||
})
|
||||
|
|
@ -193,3 +246,36 @@ func (c *Config) SortAPITokens() {
|
|||
c.APIToken = ""
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeScopes applies defaults and returns a safe copy of the input slice.
|
||||
func normalizeScopes(scopes []string) []string {
|
||||
if len(scopes) == 0 {
|
||||
return []string{ScopeWildcard}
|
||||
}
|
||||
result := make([]string, len(scopes))
|
||||
copy(result, scopes)
|
||||
return result
|
||||
}
|
||||
|
||||
// HasScope reports whether the record grants the requested scope or wildcard access.
|
||||
func (r *APITokenRecord) HasScope(scope string) bool {
|
||||
if scope == "" {
|
||||
return true
|
||||
}
|
||||
r.ensureScopes()
|
||||
for _, candidate := range r.Scopes {
|
||||
if candidate == ScopeWildcard || candidate == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsKnownScope reports whether the provided string matches a supported scope identifier.
|
||||
func IsKnownScope(scope string) bool {
|
||||
if scope == ScopeWildcard {
|
||||
return true
|
||||
}
|
||||
_, ok := scopeLookup[scope]
|
||||
return ok
|
||||
}
|
||||
|
|
|
|||
58
internal/config/api_tokens_test.go
Normal file
58
internal/config/api_tokens_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPITokenRecordHasScope(t *testing.T) {
|
||||
record := APITokenRecord{Scopes: []string{ScopeMonitoringRead}}
|
||||
|
||||
if !record.HasScope(ScopeMonitoringRead) {
|
||||
t.Fatalf("expected scope %q to be granted", ScopeMonitoringRead)
|
||||
}
|
||||
if record.HasScope(ScopeSettingsWrite) {
|
||||
t.Fatalf("did not expect scope %q to be granted", ScopeSettingsWrite)
|
||||
}
|
||||
|
||||
record.Scopes = nil // legacy tokens with no scopes should default to wildcard
|
||||
if !record.HasScope(ScopeSettingsWrite) {
|
||||
t.Fatalf("expected wildcard to grant %q", ScopeSettingsWrite)
|
||||
}
|
||||
|
||||
if !IsKnownScope(ScopeMonitoringRead) {
|
||||
t.Fatalf("expected %q to be known scope", ScopeMonitoringRead)
|
||||
}
|
||||
if IsKnownScope("unknown:scope") {
|
||||
t.Fatalf("unexpected scope recognised")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAPITokensAppliesLegacyScopes(t *testing.T) {
|
||||
if len(AllKnownScopes) == 0 {
|
||||
t.Fatal("expected known scopes to be defined")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
persistence := NewConfigPersistence(dir)
|
||||
if err := persistence.EnsureConfigDir(); err != nil {
|
||||
t.Fatalf("EnsureConfigDir: %v", err)
|
||||
}
|
||||
|
||||
payload := `[{"id":"legacy","name":"legacy","hash":"abc","createdAt":"2024-01-01T00:00:00Z"}]`
|
||||
if err := os.WriteFile(filepath.Join(dir, "api_tokens.json"), []byte(payload), 0600); err != nil {
|
||||
t.Fatalf("write api_tokens.json: %v", err)
|
||||
}
|
||||
|
||||
tokens, err := persistence.LoadAPITokens()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAPITokens: %v", err)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("expected 1 token, got %d", len(tokens))
|
||||
}
|
||||
if len(tokens[0].Scopes) != 1 || tokens[0].Scopes[0] != ScopeWildcard {
|
||||
t.Fatalf("expected legacy token to default to wildcard scope, got %#v", tokens[0].Scopes)
|
||||
}
|
||||
}
|
||||
|
|
@ -76,15 +76,15 @@ type Config struct {
|
|||
|
||||
// Monitoring settings
|
||||
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
|
||||
PBSPollingInterval time.Duration `envconfig:"PBS_POLLING_INTERVAL"` // PBS polling interval (60s default)
|
||||
PMGPollingInterval time.Duration `envconfig:"PMG_POLLING_INTERVAL"` // PMG polling interval (60s default)
|
||||
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
|
||||
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"45s"` // Increased for slow storage operations
|
||||
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
|
||||
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
|
||||
BackupPollingInterval time.Duration `envconfig:"BACKUP_POLLING_INTERVAL"`
|
||||
EnableBackupPolling bool `envconfig:"ENABLE_BACKUP_POLLING" default:"true"`
|
||||
WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"`
|
||||
PBSPollingInterval time.Duration `envconfig:"PBS_POLLING_INTERVAL"` // PBS polling interval (60s default)
|
||||
PMGPollingInterval time.Duration `envconfig:"PMG_POLLING_INTERVAL"` // PMG polling interval (60s default)
|
||||
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
|
||||
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"45s"` // Increased for slow storage operations
|
||||
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
|
||||
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
|
||||
BackupPollingInterval time.Duration `envconfig:"BACKUP_POLLING_INTERVAL"`
|
||||
EnableBackupPolling bool `envconfig:"ENABLE_BACKUP_POLLING" default:"true"`
|
||||
WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"`
|
||||
AdaptivePollingEnabled bool `envconfig:"ADAPTIVE_POLLING_ENABLED" default:"false"`
|
||||
AdaptivePollingBaseInterval time.Duration `envconfig:"ADAPTIVE_POLLING_BASE_INTERVAL" default:"10s"`
|
||||
AdaptivePollingMinInterval time.Duration `envconfig:"ADAPTIVE_POLLING_MIN_INTERVAL" default:"5s"`
|
||||
|
|
@ -485,36 +485,36 @@ func Load() (*Config, error) {
|
|||
|
||||
// Initialize config with defaults
|
||||
cfg := &Config{
|
||||
BackendHost: "0.0.0.0",
|
||||
BackendPort: 3000,
|
||||
FrontendHost: "0.0.0.0",
|
||||
FrontendPort: 7655,
|
||||
ConfigPath: dataDir,
|
||||
DataPath: dataDir,
|
||||
ConcurrentPolling: true,
|
||||
ConnectionTimeout: 60 * time.Second,
|
||||
MetricsRetentionDays: 7,
|
||||
BackupPollingCycles: 10,
|
||||
BackupPollingInterval: 0,
|
||||
EnableBackupPolling: true,
|
||||
WebhookBatchDelay: 10 * time.Second,
|
||||
BackendHost: "0.0.0.0",
|
||||
BackendPort: 3000,
|
||||
FrontendHost: "0.0.0.0",
|
||||
FrontendPort: 7655,
|
||||
ConfigPath: dataDir,
|
||||
DataPath: dataDir,
|
||||
ConcurrentPolling: true,
|
||||
ConnectionTimeout: 60 * time.Second,
|
||||
MetricsRetentionDays: 7,
|
||||
BackupPollingCycles: 10,
|
||||
BackupPollingInterval: 0,
|
||||
EnableBackupPolling: true,
|
||||
WebhookBatchDelay: 10 * time.Second,
|
||||
AdaptivePollingEnabled: false,
|
||||
AdaptivePollingBaseInterval: 10 * time.Second,
|
||||
AdaptivePollingMinInterval: 5 * time.Second,
|
||||
AdaptivePollingMaxInterval: 5 * time.Minute,
|
||||
LogLevel: "info",
|
||||
LogFormat: "auto",
|
||||
LogMaxSize: 100,
|
||||
LogMaxAge: 30,
|
||||
LogCompress: true,
|
||||
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
|
||||
IframeEmbeddingAllow: "SAMEORIGIN",
|
||||
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
|
||||
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
|
||||
DiscoveryEnabled: false,
|
||||
DiscoverySubnet: "auto",
|
||||
EnvOverrides: make(map[string]bool),
|
||||
OIDC: NewOIDCConfig(),
|
||||
LogLevel: "info",
|
||||
LogFormat: "auto",
|
||||
LogMaxSize: 100,
|
||||
LogMaxAge: 30,
|
||||
LogCompress: true,
|
||||
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
|
||||
IframeEmbeddingAllow: "SAMEORIGIN",
|
||||
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
|
||||
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
|
||||
DiscoveryEnabled: false,
|
||||
DiscoverySubnet: "auto",
|
||||
EnvOverrides: make(map[string]bool),
|
||||
OIDC: NewOIDCConfig(),
|
||||
}
|
||||
|
||||
cfg.Discovery = DefaultDiscoveryConfig()
|
||||
|
|
@ -548,26 +548,26 @@ func Load() (*Config, error) {
|
|||
cfg.PMGPollingInterval = time.Duration(systemSettings.PMGPollingInterval) * time.Second
|
||||
}
|
||||
|
||||
if systemSettings.BackupPollingInterval > 0 {
|
||||
cfg.BackupPollingInterval = time.Duration(systemSettings.BackupPollingInterval) * time.Second
|
||||
} else if systemSettings.BackupPollingInterval == 0 {
|
||||
cfg.BackupPollingInterval = 0
|
||||
}
|
||||
if systemSettings.BackupPollingEnabled != nil {
|
||||
cfg.EnableBackupPolling = *systemSettings.BackupPollingEnabled
|
||||
}
|
||||
if systemSettings.AdaptivePollingEnabled != nil {
|
||||
cfg.AdaptivePollingEnabled = *systemSettings.AdaptivePollingEnabled
|
||||
}
|
||||
if systemSettings.AdaptivePollingBaseInterval > 0 {
|
||||
cfg.AdaptivePollingBaseInterval = time.Duration(systemSettings.AdaptivePollingBaseInterval) * time.Second
|
||||
}
|
||||
if systemSettings.AdaptivePollingMinInterval > 0 {
|
||||
cfg.AdaptivePollingMinInterval = time.Duration(systemSettings.AdaptivePollingMinInterval) * time.Second
|
||||
}
|
||||
if systemSettings.AdaptivePollingMaxInterval > 0 {
|
||||
cfg.AdaptivePollingMaxInterval = time.Duration(systemSettings.AdaptivePollingMaxInterval) * time.Second
|
||||
}
|
||||
if systemSettings.BackupPollingInterval > 0 {
|
||||
cfg.BackupPollingInterval = time.Duration(systemSettings.BackupPollingInterval) * time.Second
|
||||
} else if systemSettings.BackupPollingInterval == 0 {
|
||||
cfg.BackupPollingInterval = 0
|
||||
}
|
||||
if systemSettings.BackupPollingEnabled != nil {
|
||||
cfg.EnableBackupPolling = *systemSettings.BackupPollingEnabled
|
||||
}
|
||||
if systemSettings.AdaptivePollingEnabled != nil {
|
||||
cfg.AdaptivePollingEnabled = *systemSettings.AdaptivePollingEnabled
|
||||
}
|
||||
if systemSettings.AdaptivePollingBaseInterval > 0 {
|
||||
cfg.AdaptivePollingBaseInterval = time.Duration(systemSettings.AdaptivePollingBaseInterval) * time.Second
|
||||
}
|
||||
if systemSettings.AdaptivePollingMinInterval > 0 {
|
||||
cfg.AdaptivePollingMinInterval = time.Duration(systemSettings.AdaptivePollingMinInterval) * time.Second
|
||||
}
|
||||
if systemSettings.AdaptivePollingMaxInterval > 0 {
|
||||
cfg.AdaptivePollingMaxInterval = time.Duration(systemSettings.AdaptivePollingMaxInterval) * time.Second
|
||||
}
|
||||
|
||||
if systemSettings.UpdateChannel != "" {
|
||||
cfg.UpdateChannel = systemSettings.UpdateChannel
|
||||
|
|
@ -602,11 +602,11 @@ func Load() (*Config, error) {
|
|||
} else {
|
||||
// No system.json exists - create default one
|
||||
log.Info().Msg("No system.json found, creating default")
|
||||
defaultSettings := DefaultSystemSettings()
|
||||
defaultSettings.ConnectionTimeout = int(cfg.ConnectionTimeout.Seconds())
|
||||
if err := persistence.SaveSystemSettings(*defaultSettings); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to create default system.json")
|
||||
}
|
||||
defaultSettings := DefaultSystemSettings()
|
||||
defaultSettings.ConnectionTimeout = int(cfg.ConnectionTimeout.Seconds())
|
||||
if err := persistence.SaveSystemSettings(*defaultSettings); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to create default system.json")
|
||||
}
|
||||
}
|
||||
|
||||
if oidcSettings, err := persistence.LoadOIDCConfig(); err == nil && oidcSettings != nil {
|
||||
|
|
@ -783,6 +783,7 @@ func Load() (*Config, error) {
|
|||
Prefix: prefix,
|
||||
Suffix: suffix,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Scopes: []string{ScopeWildcard},
|
||||
}
|
||||
cfg.APITokens = append(cfg.APITokens, record)
|
||||
}
|
||||
|
|
@ -800,7 +801,7 @@ func Load() (*Config, error) {
|
|||
|
||||
// Legacy migration: if a single token is present without metadata, wrap it.
|
||||
if !cfg.HasAPITokens() && cfg.APIToken != "" {
|
||||
if record, err := NewHashedAPITokenRecord(cfg.APIToken, "Legacy token", time.Now().UTC()); err == nil {
|
||||
if record, err := NewHashedAPITokenRecord(cfg.APIToken, "Legacy token", time.Now().UTC(), nil); err == nil {
|
||||
cfg.APITokens = []APITokenRecord{*record}
|
||||
cfg.SortAPITokens()
|
||||
log.Info().Msg("Migrated legacy API token into token record store")
|
||||
|
|
@ -1121,20 +1122,20 @@ func SaveConfig(cfg *Config) error {
|
|||
adaptiveEnabled := cfg.AdaptivePollingEnabled
|
||||
systemSettings := SystemSettings{
|
||||
// Note: PVE polling is hardcoded to 10s
|
||||
UpdateChannel: cfg.UpdateChannel,
|
||||
AutoUpdateEnabled: cfg.AutoUpdateEnabled,
|
||||
AutoUpdateCheckInterval: int(cfg.AutoUpdateCheckInterval.Hours()),
|
||||
AutoUpdateTime: cfg.AutoUpdateTime,
|
||||
AllowedOrigins: cfg.AllowedOrigins,
|
||||
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
|
||||
LogLevel: cfg.LogLevel,
|
||||
DiscoveryEnabled: cfg.DiscoveryEnabled,
|
||||
DiscoverySubnet: cfg.DiscoverySubnet,
|
||||
DiscoveryConfig: CloneDiscoveryConfig(cfg.Discovery),
|
||||
AdaptivePollingEnabled: &adaptiveEnabled,
|
||||
AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second),
|
||||
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
|
||||
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
|
||||
UpdateChannel: cfg.UpdateChannel,
|
||||
AutoUpdateEnabled: cfg.AutoUpdateEnabled,
|
||||
AutoUpdateCheckInterval: int(cfg.AutoUpdateCheckInterval.Hours()),
|
||||
AutoUpdateTime: cfg.AutoUpdateTime,
|
||||
AllowedOrigins: cfg.AllowedOrigins,
|
||||
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
|
||||
LogLevel: cfg.LogLevel,
|
||||
DiscoveryEnabled: cfg.DiscoveryEnabled,
|
||||
DiscoverySubnet: cfg.DiscoverySubnet,
|
||||
DiscoveryConfig: CloneDiscoveryConfig(cfg.Discovery),
|
||||
AdaptivePollingEnabled: &adaptiveEnabled,
|
||||
AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second),
|
||||
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
|
||||
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
|
||||
// APIToken removed - now handled via .env only
|
||||
}
|
||||
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,10 @@ func (c *ConfigPersistence) LoadAPITokens() ([]APITokenRecord, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
for i := range tokens {
|
||||
tokens[i].ensureScopes()
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +149,14 @@ func (c *ConfigPersistence) SaveAPITokens(tokens []APITokenRecord) error {
|
|||
}
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(tokens, "", " ")
|
||||
sanitized := make([]APITokenRecord, len(tokens))
|
||||
for i := range tokens {
|
||||
record := tokens[i]
|
||||
record.ensureScopes()
|
||||
sanitized[i] = record
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(sanitized, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ func TestExportConfigIncludesAPITokens(t *testing.T) {
|
|||
Prefix: "hash-1",
|
||||
Suffix: "-0001",
|
||||
CreatedAt: createdAt,
|
||||
Scopes: []string{config.ScopeWildcard},
|
||||
},
|
||||
{
|
||||
ID: "token-2",
|
||||
|
|
@ -300,6 +301,7 @@ func TestExportConfigIncludesAPITokens(t *testing.T) {
|
|||
Prefix: "hash-2",
|
||||
Suffix: "-0002",
|
||||
CreatedAt: createdAt.Add(time.Hour),
|
||||
Scopes: []string{config.ScopeMonitoringRead},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -401,6 +403,7 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
|
|||
Prefix: "hashn1",
|
||||
Suffix: "n1",
|
||||
CreatedAt: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Scopes: []string{config.ScopeMonitoringRead, config.ScopeMonitoringWrite},
|
||||
},
|
||||
}
|
||||
if err := source.SaveAPITokens(newTokens); err != nil {
|
||||
|
|
@ -467,6 +470,7 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
|
|||
Prefix: "hasho1",
|
||||
Suffix: "o1",
|
||||
CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Scopes: []string{config.ScopeWildcard},
|
||||
},
|
||||
}
|
||||
if err := target.SaveAPITokens(oldTokens); err != nil {
|
||||
|
|
@ -569,6 +573,7 @@ func TestImportConfigRollbackOnFailure(t *testing.T) {
|
|||
Prefix: "hashn",
|
||||
Suffix: "-n",
|
||||
CreatedAt: time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC),
|
||||
Scopes: []string{config.ScopeDockerReport},
|
||||
},
|
||||
}
|
||||
if err := source.SaveAPITokens(newTokens); err != nil {
|
||||
|
|
@ -619,6 +624,7 @@ func TestImportConfigRollbackOnFailure(t *testing.T) {
|
|||
Prefix: "hasho",
|
||||
Suffix: "-o",
|
||||
CreatedAt: time.Date(2023, 3, 3, 12, 0, 0, 0, time.UTC),
|
||||
Scopes: []string{config.ScopeWildcard},
|
||||
},
|
||||
}
|
||||
if err := target.SaveAPITokens(baselineTokens); err != nil {
|
||||
|
|
@ -763,6 +769,7 @@ func TestImportAcceptsVersion40Bundle(t *testing.T) {
|
|||
Prefix: "hashk",
|
||||
Suffix: "-k",
|
||||
CreatedAt: time.Date(2022, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Scopes: []string{config.ScopeWildcard},
|
||||
},
|
||||
}
|
||||
if err := target.SaveAPITokens(baselineTokens); err != nil {
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ func (cw *ConfigWatcher) reloadConfig() {
|
|||
Prefix: prefix,
|
||||
Suffix: suffix,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Scopes: []string{ScopeWildcard},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
474
internal/hostagent/agent.go
Normal file
474
internal/hostagent/agent.go
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
package hostagent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||
"github.com/rs/zerolog"
|
||||
gocpu "github.com/shirou/gopsutil/v3/cpu"
|
||||
godisk "github.com/shirou/gopsutil/v3/disk"
|
||||
gohost "github.com/shirou/gopsutil/v3/host"
|
||||
goload "github.com/shirou/gopsutil/v3/load"
|
||||
gomem "github.com/shirou/gopsutil/v3/mem"
|
||||
gonet "github.com/shirou/gopsutil/v3/net"
|
||||
)
|
||||
|
||||
// Config controls the behaviour of the host agent.
|
||||
type Config struct {
|
||||
PulseURL string
|
||||
APIToken string
|
||||
Interval time.Duration
|
||||
HostnameOverride string
|
||||
AgentID string
|
||||
Tags []string
|
||||
InsecureSkipVerify bool
|
||||
RunOnce bool
|
||||
Logger *zerolog.Logger
|
||||
}
|
||||
|
||||
// Agent is responsible for collecting host metrics and shipping them to Pulse.
|
||||
type Agent struct {
|
||||
cfg Config
|
||||
logger zerolog.Logger
|
||||
httpClient *http.Client
|
||||
|
||||
hostInfo *gohost.InfoStat
|
||||
hostname string
|
||||
displayName string
|
||||
platform string
|
||||
osName string
|
||||
osVersion string
|
||||
kernelVersion string
|
||||
architecture string
|
||||
machineID string
|
||||
agentID string
|
||||
interval time.Duration
|
||||
trimmedPulseURL string
|
||||
|
||||
prevCPUTimes *gocpu.TimesStat
|
||||
}
|
||||
|
||||
const defaultInterval = 30 * time.Second
|
||||
|
||||
// New constructs a fully initialised host Agent.
|
||||
func New(cfg Config) (*Agent, error) {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = defaultInterval
|
||||
}
|
||||
|
||||
if cfg.Logger == nil {
|
||||
defaultLogger := zerolog.New(zerolog.NewConsoleWriter()).With().Timestamp().Logger()
|
||||
cfg.Logger = &defaultLogger
|
||||
}
|
||||
|
||||
logger := cfg.Logger.With().Str("component", "host-agent").Logger()
|
||||
|
||||
if strings.TrimSpace(cfg.APIToken) == "" {
|
||||
return nil, fmt.Errorf("api token is required")
|
||||
}
|
||||
|
||||
pulseURL := cfg.PulseURL
|
||||
if strings.TrimSpace(pulseURL) == "" {
|
||||
pulseURL = "http://localhost:7655"
|
||||
}
|
||||
pulseURL = strings.TrimRight(pulseURL, "/")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
info, err := gohost.InfoWithContext(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch host info: %w", err)
|
||||
}
|
||||
|
||||
hostname := strings.TrimSpace(cfg.HostnameOverride)
|
||||
if hostname == "" {
|
||||
hostname = strings.TrimSpace(info.Hostname)
|
||||
}
|
||||
if hostname == "" {
|
||||
hostname = "unknown-host"
|
||||
}
|
||||
|
||||
displayName := hostname
|
||||
|
||||
machineID := strings.TrimSpace(info.HostID)
|
||||
|
||||
agentID := strings.TrimSpace(cfg.AgentID)
|
||||
if agentID == "" {
|
||||
agentID = machineID
|
||||
}
|
||||
if agentID == "" {
|
||||
agentID = hostname
|
||||
}
|
||||
|
||||
platform := normalisePlatform(info.Platform)
|
||||
osName := strings.TrimSpace(info.PlatformFamily)
|
||||
if osName == "" {
|
||||
osName = strings.TrimSpace(info.Platform)
|
||||
}
|
||||
osVersion := strings.TrimSpace(info.PlatformVersion)
|
||||
kernelVersion := strings.TrimSpace(info.KernelVersion)
|
||||
arch := strings.TrimSpace(info.KernelArch)
|
||||
if arch == "" {
|
||||
arch = runtime.GOARCH
|
||||
}
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if cfg.InsecureSkipVerify {
|
||||
//nolint:gosec // Insecure mode is explicitly user-controlled.
|
||||
tlsConfig.InsecureSkipVerify = true
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: tlsConfig,
|
||||
},
|
||||
}
|
||||
|
||||
trimmedTags := make([]string, 0, len(cfg.Tags))
|
||||
seenTags := make(map[string]struct{}, len(cfg.Tags))
|
||||
for _, tag := range cfg.Tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenTags[tag]; exists {
|
||||
continue
|
||||
}
|
||||
seenTags[tag] = struct{}{}
|
||||
trimmedTags = append(trimmedTags, tag)
|
||||
}
|
||||
cfg.Tags = trimmedTags
|
||||
|
||||
return &Agent{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
httpClient: client,
|
||||
hostInfo: info,
|
||||
hostname: hostname,
|
||||
displayName: displayName,
|
||||
platform: platform,
|
||||
osName: osName,
|
||||
osVersion: osVersion,
|
||||
kernelVersion: kernelVersion,
|
||||
architecture: arch,
|
||||
machineID: machineID,
|
||||
agentID: agentID,
|
||||
interval: cfg.Interval,
|
||||
trimmedPulseURL: pulseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run executes the agent until the context is cancelled.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
if a.cfg.RunOnce {
|
||||
return a.runOnce(ctx)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(a.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
if err := a.process(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
a.logger.Error().Err(err).Msg("initial report failed")
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := a.process(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
a.logger.Error().Err(err).Msg("failed to send report")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) runOnce(ctx context.Context) error {
|
||||
return a.process(ctx)
|
||||
}
|
||||
|
||||
func (a *Agent) process(ctx context.Context) error {
|
||||
report, err := a.buildReport(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build report: %w", err)
|
||||
}
|
||||
if err := a.sendReport(ctx, report); err != nil {
|
||||
return fmt.Errorf("send report: %w", err)
|
||||
}
|
||||
a.logger.Debug().
|
||||
Str("hostname", report.Host.Hostname).
|
||||
Str("platform", report.Host.Platform).
|
||||
Msg("host report sent")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
|
||||
collectCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
uptime, _ := gohost.UptimeWithContext(collectCtx)
|
||||
loadAvg, _ := goload.AvgWithContext(collectCtx)
|
||||
cpuCount, _ := gocpu.CountsWithContext(collectCtx, true)
|
||||
cpuUsage, err := a.calculateCPUUsage(collectCtx)
|
||||
if err != nil {
|
||||
a.logger.Debug().Err(err).Msg("failed to compute cpu usage")
|
||||
}
|
||||
|
||||
memStats, err := gomem.VirtualMemoryWithContext(collectCtx)
|
||||
if err != nil {
|
||||
return agentshost.Report{}, fmt.Errorf("memory stats: %w", err)
|
||||
}
|
||||
|
||||
disks := a.collectDisks(collectCtx)
|
||||
network := a.collectNetwork(collectCtx)
|
||||
|
||||
var loadValues []float64
|
||||
if loadAvg != nil {
|
||||
loadValues = []float64{loadAvg.Load1, loadAvg.Load5, loadAvg.Load15}
|
||||
}
|
||||
|
||||
swapUsed := int64(0)
|
||||
if memStats.SwapTotal > memStats.SwapFree {
|
||||
swapUsed = int64(memStats.SwapTotal - memStats.SwapFree)
|
||||
}
|
||||
|
||||
report := agentshost.Report{
|
||||
Agent: agentshost.AgentInfo{
|
||||
ID: a.agentID,
|
||||
Version: Version,
|
||||
IntervalSeconds: int(a.interval / time.Second),
|
||||
Hostname: a.hostname,
|
||||
},
|
||||
Host: agentshost.HostInfo{
|
||||
ID: a.machineID,
|
||||
Hostname: a.hostname,
|
||||
DisplayName: a.displayName,
|
||||
MachineID: a.machineID,
|
||||
Platform: a.platform,
|
||||
OSName: a.osName,
|
||||
OSVersion: a.osVersion,
|
||||
KernelVersion: a.kernelVersion,
|
||||
Architecture: a.architecture,
|
||||
CPUModel: "",
|
||||
CPUCount: cpuCount,
|
||||
UptimeSeconds: int64(uptime),
|
||||
LoadAverage: loadValues,
|
||||
},
|
||||
Metrics: agentshost.Metrics{
|
||||
CPUUsagePercent: cpuUsage,
|
||||
Memory: agentshost.MemoryMetric{
|
||||
TotalBytes: int64(memStats.Total),
|
||||
UsedBytes: int64(memStats.Used),
|
||||
FreeBytes: int64(memStats.Free),
|
||||
Usage: memStats.UsedPercent,
|
||||
SwapTotal: int64(memStats.SwapTotal),
|
||||
SwapUsed: swapUsed,
|
||||
},
|
||||
},
|
||||
Disks: disks,
|
||||
Network: network,
|
||||
Sensors: agentshost.Sensors{},
|
||||
Tags: append([]string(nil), a.cfg.Tags...),
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (a *Agent) calculateCPUUsage(ctx context.Context) (float64, error) {
|
||||
times, err := gocpu.TimesWithContext(ctx, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(times) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
current := times[0]
|
||||
|
||||
if a.prevCPUTimes == nil {
|
||||
a.prevCPUTimes = ¤t
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
prev := a.prevCPUTimes
|
||||
a.prevCPUTimes = ¤t
|
||||
|
||||
deltaTotal := current.Total() - prev.Total()
|
||||
if deltaTotal <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
deltaIdle := current.Idle - prev.Idle
|
||||
if deltaIdle < 0 {
|
||||
deltaIdle = 0
|
||||
}
|
||||
|
||||
usage := (1 - (deltaIdle / deltaTotal)) * 100
|
||||
if usage < 0 {
|
||||
usage = 0
|
||||
}
|
||||
if usage > 100 {
|
||||
usage = 100
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func (a *Agent) collectDisks(ctx context.Context) []agentshost.Disk {
|
||||
partitions, err := godisk.PartitionsWithContext(ctx, true)
|
||||
if err != nil {
|
||||
a.logger.Debug().Err(err).Msg("failed to fetch disk partitions")
|
||||
return nil
|
||||
}
|
||||
|
||||
disks := make([]agentshost.Disk, 0, len(partitions))
|
||||
seen := make(map[string]struct{}, len(partitions))
|
||||
|
||||
for _, part := range partitions {
|
||||
if part.Mountpoint == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[part.Mountpoint]; ok {
|
||||
continue
|
||||
}
|
||||
seen[part.Mountpoint] = struct{}{}
|
||||
|
||||
usage, err := godisk.UsageWithContext(ctx, part.Mountpoint)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if usage.Total == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
disks = append(disks, agentshost.Disk{
|
||||
Device: part.Device,
|
||||
Mountpoint: part.Mountpoint,
|
||||
Filesystem: part.Fstype,
|
||||
Type: part.Fstype,
|
||||
TotalBytes: int64(usage.Total),
|
||||
UsedBytes: int64(usage.Used),
|
||||
FreeBytes: int64(usage.Free),
|
||||
Usage: usage.UsedPercent,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(disks, func(i, j int) bool { return disks[i].Mountpoint < disks[j].Mountpoint })
|
||||
return disks
|
||||
}
|
||||
|
||||
func (a *Agent) collectNetwork(ctx context.Context) []agentshost.NetworkInterface {
|
||||
ifaces, err := gonet.InterfacesWithContext(ctx)
|
||||
if err != nil {
|
||||
a.logger.Debug().Err(err).Msg("failed to fetch network interfaces")
|
||||
return nil
|
||||
}
|
||||
|
||||
ioCounters, err := gonet.IOCountersWithContext(ctx, true)
|
||||
if err != nil {
|
||||
a.logger.Debug().Err(err).Msg("failed to fetch network counters")
|
||||
}
|
||||
ioMap := make(map[string]gonet.IOCountersStat, len(ioCounters))
|
||||
for _, stat := range ioCounters {
|
||||
ioMap[stat.Name] = stat
|
||||
}
|
||||
|
||||
interfaces := make([]agentshost.NetworkInterface, 0, len(ifaces))
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if len(iface.Addrs) == 0 {
|
||||
continue
|
||||
}
|
||||
if isLoopback(iface.Flags) {
|
||||
continue
|
||||
}
|
||||
|
||||
addresses := make([]string, 0, len(iface.Addrs))
|
||||
for _, addr := range iface.Addrs {
|
||||
if addr.Addr != "" {
|
||||
addresses = append(addresses, addr.Addr)
|
||||
}
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
counter := ioMap[iface.Name]
|
||||
ifaceEntry := agentshost.NetworkInterface{
|
||||
Name: iface.Name,
|
||||
MAC: iface.HardwareAddr,
|
||||
Addresses: addresses,
|
||||
RXBytes: counter.BytesRecv,
|
||||
TXBytes: counter.BytesSent,
|
||||
}
|
||||
|
||||
interfaces = append(interfaces, ifaceEntry)
|
||||
}
|
||||
|
||||
sort.Slice(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name })
|
||||
return interfaces
|
||||
}
|
||||
|
||||
func (a *Agent) sendReport(ctx context.Context, report agentshost.Report) error {
|
||||
payload, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal report: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/agents/host/report", a.trimmedPulseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.cfg.APIToken)
|
||||
req.Header.Set("X-API-Token", a.cfg.APIToken)
|
||||
req.Header.Set("User-Agent", "pulse-host-agent/"+Version)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("pulse responded with status %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalisePlatform(platform string) string {
|
||||
platform = strings.ToLower(strings.TrimSpace(platform))
|
||||
switch platform {
|
||||
case "darwin":
|
||||
return "macos"
|
||||
default:
|
||||
return platform
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopback(flags []string) bool {
|
||||
for _, flag := range flags {
|
||||
if strings.EqualFold(flag, "loopback") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
5
internal/hostagent/version.go
Normal file
5
internal/hostagent/version.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package hostagent
|
||||
|
||||
// Version is the semantic version of the Pulse host agent binary. It can be
|
||||
// overridden at build time via -ldflags when producing release artefacts.
|
||||
var Version = "0.1.0-dev"
|
||||
|
|
@ -18,12 +18,16 @@ type MockConfig struct {
|
|||
LXCsPerNode int
|
||||
DockerHostCount int
|
||||
DockerContainersPerHost int
|
||||
GenericHostCount int
|
||||
RandomMetrics bool
|
||||
HighLoadNodes []string // Specific nodes to simulate high load
|
||||
StoppedPercent float64 // Percentage of guests that should be stopped
|
||||
}
|
||||
|
||||
const dockerConnectionPrefix = "docker-"
|
||||
const (
|
||||
dockerConnectionPrefix = "docker-"
|
||||
hostConnectionPrefix = "host-"
|
||||
)
|
||||
|
||||
var DefaultConfig = MockConfig{
|
||||
NodeCount: 7, // Test the 5-9 node range by default
|
||||
|
|
@ -31,6 +35,7 @@ var DefaultConfig = MockConfig{
|
|||
LXCsPerNode: 8,
|
||||
DockerHostCount: 3,
|
||||
DockerContainersPerHost: 12,
|
||||
GenericHostCount: 4,
|
||||
RandomMetrics: true,
|
||||
StoppedPercent: 0.2,
|
||||
}
|
||||
|
|
@ -80,6 +85,35 @@ var dockerAgentVersions = []string{
|
|||
"0.1.0-dev",
|
||||
}
|
||||
|
||||
var genericHostProfiles = []struct {
|
||||
Platform string
|
||||
OSName string
|
||||
OSVersion string
|
||||
Kernel string
|
||||
Architecture string
|
||||
}{
|
||||
{"linux", "Debian GNU/Linux", "12 (bookworm)", "6.8.12-1-amd64", "x86_64"},
|
||||
{"linux", "Ubuntu Server", "24.04 LTS", "6.8.0-31-generic", "x86_64"},
|
||||
{"linux", "Rocky Linux", "9.3", "5.14.0-427.22.1.el9_4.x86_64", "x86_64"},
|
||||
{"linux", "Alpine Linux", "3.20.1", "6.6.32-0-lts", "x86_64"},
|
||||
{"windows", "Windows Server", "2022 Datacenter", "10.0.20348.2244", "x86_64"},
|
||||
{"windows", "Windows 11 Pro", "23H2", "10.0.22631.3737", "x86_64"},
|
||||
{"macos", "macOS Ventura", "13.6.8", "22.6.0", "arm64"},
|
||||
{"macos", "macOS Sonoma", "14.6.1", "23G93", "arm64"},
|
||||
}
|
||||
|
||||
var genericHostPrefixes = []string{
|
||||
"apollo", "centauri", "ceres", "europa", "hyperion",
|
||||
"kepler", "meridian", "orion", "polaris", "spectrum",
|
||||
"vega", "zenith", "halcyon", "icarus", "rigel",
|
||||
}
|
||||
|
||||
var hostAgentVersions = []string{
|
||||
"0.1.0",
|
||||
"0.1.1",
|
||||
"0.2.0-alpha",
|
||||
}
|
||||
|
||||
// Common tags used for VMs and containers
|
||||
var commonTags = []string{
|
||||
"production", "staging", "development", "testing",
|
||||
|
|
@ -170,6 +204,7 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
|
|||
data := models.StateSnapshot{
|
||||
Nodes: generateNodes(config),
|
||||
DockerHosts: generateDockerHosts(config),
|
||||
Hosts: generateHosts(config),
|
||||
VMs: []models.VM{},
|
||||
Containers: []models.Container{},
|
||||
PhysicalDisks: []models.PhysicalDisk{},
|
||||
|
|
@ -189,6 +224,10 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
|
|||
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = host.Status != "offline"
|
||||
}
|
||||
|
||||
for _, host := range data.Hosts {
|
||||
data.ConnectionHealth[hostConnectionPrefix+host.ID] = host.Status != "offline"
|
||||
}
|
||||
|
||||
// Generate VMs and containers for each node
|
||||
vmidCounter := 100
|
||||
for nodeIdx, node := range data.Nodes {
|
||||
|
|
@ -1066,6 +1105,192 @@ func generateDockerHosts(config MockConfig) []models.DockerHost {
|
|||
return hosts
|
||||
}
|
||||
|
||||
func generateHosts(config MockConfig) []models.Host {
|
||||
count := config.GenericHostCount
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
hosts := make([]models.Host, 0, count)
|
||||
usedNames := make(map[string]struct{}, count)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
profile := genericHostProfiles[rand.Intn(len(genericHostProfiles))]
|
||||
|
||||
baseName := genericHostPrefixes[rand.Intn(len(genericHostPrefixes))]
|
||||
suffix := 1 + rand.Intn(900)
|
||||
hostname := fmt.Sprintf("%s-%d", baseName, suffix)
|
||||
for {
|
||||
if _, exists := usedNames[hostname]; !exists {
|
||||
usedNames[hostname] = struct{}{}
|
||||
break
|
||||
}
|
||||
suffix++
|
||||
hostname = fmt.Sprintf("%s-%d", baseName, suffix)
|
||||
}
|
||||
|
||||
displayName := strings.ToUpper(hostname[:1]) + hostname[1:]
|
||||
|
||||
cpuCount := 4 + rand.Intn(28) // 4-32 cores
|
||||
if profile.Platform == "macos" {
|
||||
cpuCount = 8 + rand.Intn(10)
|
||||
}
|
||||
cpuUsage := clampFloat(10+rand.Float64()*55, 4, 94)
|
||||
|
||||
memTotalGiB := 16 + rand.Intn(192)
|
||||
if profile.Platform == "macos" {
|
||||
memTotalGiB = 16 + rand.Intn(64)
|
||||
}
|
||||
memTotal := int64(memTotalGiB) << 30
|
||||
memUsage := clampFloat(30+rand.Float64()*50, 12, 96)
|
||||
memUsed := int64(float64(memTotal) * (memUsage / 100.0))
|
||||
memFree := memTotal - memUsed
|
||||
|
||||
swapTotal := int64(rand.Intn(32)) << 30
|
||||
swapUsed := int64(float64(swapTotal) * rand.Float64())
|
||||
|
||||
rootDiskTotal := int64(120+rand.Intn(680)) << 30
|
||||
rootDiskUsage := clampFloat(25+rand.Float64()*55, 8, 95)
|
||||
rootDiskUsed := int64(float64(rootDiskTotal) * (rootDiskUsage / 100.0))
|
||||
rootDisk := models.Disk{
|
||||
Total: rootDiskTotal,
|
||||
Used: rootDiskUsed,
|
||||
Free: rootDiskTotal - rootDiskUsed,
|
||||
Usage: rootDiskUsage,
|
||||
Mountpoint: "/",
|
||||
Type: "ext4",
|
||||
Device: "/dev/sda1",
|
||||
}
|
||||
if profile.Platform == "windows" {
|
||||
rootDisk.Mountpoint = "C:"
|
||||
rootDisk.Type = "ntfs"
|
||||
rootDisk.Device = `\\.\PHYSICALDRIVE0`
|
||||
}
|
||||
if profile.Platform == "macos" {
|
||||
rootDisk.Type = "apfs"
|
||||
rootDisk.Device = "/dev/disk1s1"
|
||||
}
|
||||
|
||||
disks := []models.Disk{rootDisk}
|
||||
if rand.Float64() < 0.45 {
|
||||
dataDiskTotal := int64(200+rand.Intn(1400)) << 30
|
||||
dataDiskUsage := clampFloat(35+rand.Float64()*45, 6, 97)
|
||||
dataDiskUsed := int64(float64(dataDiskTotal) * (dataDiskUsage / 100.0))
|
||||
mount := "/data"
|
||||
device := "/dev/sdb1"
|
||||
fsType := "xfs"
|
||||
if profile.Platform == "windows" {
|
||||
mount = "D:"
|
||||
device = `\\.\PHYSICALDRIVE1`
|
||||
fsType = "ntfs"
|
||||
}
|
||||
if profile.Platform == "macos" {
|
||||
mount = "/Volumes/Data"
|
||||
device = "/dev/disk3s1"
|
||||
fsType = "apfs"
|
||||
}
|
||||
disks = append(disks, models.Disk{
|
||||
Total: dataDiskTotal,
|
||||
Used: dataDiskUsed,
|
||||
Free: dataDiskTotal - dataDiskUsed,
|
||||
Usage: dataDiskUsage,
|
||||
Mountpoint: mount,
|
||||
Type: fsType,
|
||||
Device: device,
|
||||
})
|
||||
}
|
||||
|
||||
primaryIP := fmt.Sprintf("192.168.%d.%d", 10+rand.Intn(60), 10+rand.Intn(200))
|
||||
network := []models.HostNetworkInterface{
|
||||
{
|
||||
Name: "eth0",
|
||||
MAC: fmt.Sprintf("02:42:%02x:%02x:%02x:%02x", rand.Intn(256), rand.Intn(256), rand.Intn(256), rand.Intn(256)),
|
||||
Addresses: []string{primaryIP},
|
||||
RXBytes: uint64(256+rand.Intn(4096)) * 1024 * 1024,
|
||||
TXBytes: uint64(256+rand.Intn(4096)) * 1024 * 1024,
|
||||
},
|
||||
}
|
||||
if rand.Float64() < 0.32 {
|
||||
network[0].Addresses = append(network[0].Addresses, fmt.Sprintf("10.%d.%d.%d", 10+rand.Intn(90), rand.Intn(200), rand.Intn(200)))
|
||||
}
|
||||
|
||||
var loadAverage []float64
|
||||
if profile.Platform == "linux" {
|
||||
loadAverage = []float64{
|
||||
clampFloat(rand.Float64()*float64(cpuCount)/4, 0.05, float64(cpuCount)*0.8),
|
||||
clampFloat(rand.Float64()*float64(cpuCount)/4, 0.05, float64(cpuCount)*0.8),
|
||||
clampFloat(rand.Float64()*float64(cpuCount)/4, 0.05, float64(cpuCount)*0.8),
|
||||
}
|
||||
}
|
||||
|
||||
sensors := models.HostSensorSummary{}
|
||||
if profile.Platform == "linux" || profile.Platform == "macos" {
|
||||
sensors.TemperatureCelsius = map[string]float64{
|
||||
"cpu.package": clampFloat(38+rand.Float64()*22, 30, 85),
|
||||
}
|
||||
if rand.Float64() < 0.4 {
|
||||
sensors.Additional = map[string]float64{
|
||||
"nvme0": clampFloat(40+rand.Float64()*20, 30, 90),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status := "online"
|
||||
if rand.Float64() < 0.1 {
|
||||
status = "offline"
|
||||
} else if rand.Float64() < 0.12 {
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
lastSeen := now.Add(-time.Duration(rand.Intn(60)) * time.Second)
|
||||
if status == "offline" {
|
||||
lastSeen = now.Add(-time.Duration(300+rand.Intn(2400)) * time.Second)
|
||||
}
|
||||
|
||||
uptimeSeconds := int64(3600*(12+rand.Intn(720))) + int64(rand.Intn(3600))
|
||||
intervalSeconds := 30 + rand.Intn(45)
|
||||
|
||||
tags := make([]string, 0, 2)
|
||||
for _, candidate := range []string{"production", "lab", "edge", "backup", "database", "web"} {
|
||||
if rand.Float64() < 0.18 {
|
||||
tags = append(tags, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
host := models.Host{
|
||||
ID: fmt.Sprintf("host-%s-%d", profile.Platform, i+1),
|
||||
Hostname: hostname,
|
||||
DisplayName: displayName,
|
||||
Platform: profile.Platform,
|
||||
OSName: profile.OSName,
|
||||
OSVersion: profile.OSVersion,
|
||||
KernelVersion: profile.Kernel,
|
||||
Architecture: profile.Architecture,
|
||||
CPUCount: cpuCount,
|
||||
CPUUsage: cpuUsage,
|
||||
LoadAverage: loadAverage,
|
||||
Memory: models.Memory{Total: memTotal, Used: memUsed, Free: memFree, Usage: memUsage, SwapTotal: swapTotal, SwapUsed: swapUsed},
|
||||
Disks: disks,
|
||||
NetworkInterfaces: network,
|
||||
Sensors: sensors,
|
||||
Status: status,
|
||||
UptimeSeconds: uptimeSeconds,
|
||||
IntervalSeconds: intervalSeconds,
|
||||
LastSeen: lastSeen,
|
||||
AgentVersion: hostAgentVersions[rand.Intn(len(hostAgentVersions))],
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
|
||||
sort.Slice(hosts, func(i, j int) bool {
|
||||
return hosts[i].Hostname < hosts[j].Hostname
|
||||
})
|
||||
|
||||
return hosts
|
||||
}
|
||||
func generateDockerContainers(hostName string, hostIdx int, config MockConfig) []models.DockerContainer {
|
||||
base := config.DockerContainersPerHost
|
||||
if base < 1 {
|
||||
|
|
@ -2669,6 +2894,7 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
|
|||
// UpdateMetrics simulates changing metrics over time
|
||||
func UpdateMetrics(data *models.StateSnapshot, config MockConfig) {
|
||||
updateDockerHosts(data, config)
|
||||
updateHosts(data, config)
|
||||
|
||||
if !config.RandomMetrics {
|
||||
return
|
||||
|
|
@ -3018,6 +3244,70 @@ func updateDockerHosts(data *models.StateSnapshot, config MockConfig) {
|
|||
}
|
||||
}
|
||||
|
||||
func updateHosts(data *models.StateSnapshot, config MockConfig) {
|
||||
if len(data.Hosts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
step := int64(updateInterval.Seconds())
|
||||
if step <= 0 {
|
||||
step = 2
|
||||
}
|
||||
|
||||
for i := range data.Hosts {
|
||||
host := &data.Hosts[i]
|
||||
|
||||
if data.ConnectionHealth != nil {
|
||||
data.ConnectionHealth[hostConnectionPrefix+host.ID] = host.Status != "offline"
|
||||
}
|
||||
|
||||
if host.Status == "offline" {
|
||||
if config.RandomMetrics && rand.Float64() < 0.02 {
|
||||
host.Status = "online"
|
||||
host.LastSeen = now
|
||||
host.UptimeSeconds = int64(120 + rand.Intn(3600))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
host.LastSeen = now.Add(-time.Duration(rand.Intn(25)) * time.Second)
|
||||
host.UptimeSeconds += step
|
||||
|
||||
if !config.RandomMetrics {
|
||||
continue
|
||||
}
|
||||
|
||||
host.CPUUsage = clampFloat(host.CPUUsage+(rand.Float64()-0.5)*5, 4, 97)
|
||||
|
||||
memUsage := clampFloat(host.Memory.Usage+(rand.Float64()-0.5)*3, 12, 96)
|
||||
host.Memory.Usage = memUsage
|
||||
host.Memory.Used = int64(float64(host.Memory.Total) * (memUsage / 100.0))
|
||||
host.Memory.Free = host.Memory.Total - host.Memory.Used
|
||||
|
||||
for j := range host.Disks {
|
||||
change := (rand.Float64() - 0.5) * 1.2
|
||||
host.Disks[j].Usage = clampFloat(host.Disks[j].Usage+change, 5, 98)
|
||||
host.Disks[j].Used = int64(float64(host.Disks[j].Total) * (host.Disks[j].Usage / 100.0))
|
||||
host.Disks[j].Free = host.Disks[j].Total - host.Disks[j].Used
|
||||
}
|
||||
|
||||
if len(host.LoadAverage) == 3 {
|
||||
for j := range host.LoadAverage {
|
||||
host.LoadAverage[j] = clampFloat(host.LoadAverage[j]+(rand.Float64()-0.5)*0.4, 0.05, float64(host.CPUCount))
|
||||
}
|
||||
}
|
||||
|
||||
if host.Status == "degraded" {
|
||||
if rand.Float64() < 0.25 {
|
||||
host.Status = "online"
|
||||
}
|
||||
} else if rand.Float64() < 0.05 {
|
||||
host.Status = "degraded"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fluctuateFloat(value, variance, min, max float64) float64 {
|
||||
change := (rand.Float64()*2 - 1) * variance
|
||||
newValue := value * (1 + change)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ type StateSnapshot struct {
|
|||
VMs []VM `json:"vms"`
|
||||
Containers []Container `json:"containers"`
|
||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||
Hosts []Host `json:"hosts"`
|
||||
Storage []Storage `json:"storage"`
|
||||
CephClusters []CephCluster `json:"cephClusters"`
|
||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||
|
|
@ -46,6 +47,7 @@ func (s *State) GetSnapshot() StateSnapshot {
|
|||
VMs: append([]VM{}, s.VMs...),
|
||||
Containers: append([]Container{}, s.Containers...),
|
||||
DockerHosts: append([]DockerHost{}, s.DockerHosts...),
|
||||
Hosts: append([]Host{}, s.Hosts...),
|
||||
Storage: append([]Storage{}, s.Storage...),
|
||||
CephClusters: append([]CephCluster{}, s.CephClusters...),
|
||||
PhysicalDisks: append([]PhysicalDisk{}, s.PhysicalDisks...),
|
||||
|
|
@ -102,6 +104,11 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
|
|||
dockerHosts[i] = host.ToFrontend()
|
||||
}
|
||||
|
||||
hosts := make([]HostFrontend, len(s.Hosts))
|
||||
for i, host := range s.Hosts {
|
||||
hosts[i] = host.ToFrontend()
|
||||
}
|
||||
|
||||
// Convert storage
|
||||
storage := make([]StorageFrontend, len(s.Storage))
|
||||
for i, st := range s.Storage {
|
||||
|
|
@ -124,6 +131,7 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
|
|||
VMs: vms,
|
||||
Containers: containers,
|
||||
DockerHosts: dockerHosts,
|
||||
Hosts: hosts,
|
||||
Storage: storage,
|
||||
CephClusters: cephClusters,
|
||||
PhysicalDisks: s.PhysicalDisks,
|
||||
|
|
|
|||
86
pkg/agents/host/report.go
Normal file
86
pkg/agents/host/report.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package host
|
||||
|
||||
import "time"
|
||||
|
||||
// Report represents the payload sent by the pulse-host-agent.
|
||||
type Report struct {
|
||||
Agent AgentInfo `json:"agent"`
|
||||
Host HostInfo `json:"host"`
|
||||
Metrics Metrics `json:"metrics"`
|
||||
Disks []Disk `json:"disks,omitempty"`
|
||||
Network []NetworkInterface `json:"network,omitempty"`
|
||||
Sensors Sensors `json:"sensors,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
SequenceID string `json:"sequenceId,omitempty"`
|
||||
}
|
||||
|
||||
// AgentInfo describes the reporting agent.
|
||||
type AgentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version,omitempty"`
|
||||
IntervalSeconds int `json:"intervalSeconds,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
}
|
||||
|
||||
// HostInfo contains platform and identification details about the monitored host.
|
||||
type HostInfo struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Hostname string `json:"hostname"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
MachineID string `json:"machineId,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
OSName string `json:"osName,omitempty"`
|
||||
OSVersion string `json:"osVersion,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
CPUModel string `json:"cpuModel,omitempty"`
|
||||
CPUCount int `json:"cpuCount,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
|
||||
LoadAverage []float64 `json:"loadAverage,omitempty"`
|
||||
}
|
||||
|
||||
// Metrics encapsulates primary resource metrics for a host.
|
||||
type Metrics struct {
|
||||
CPUUsagePercent float64 `json:"cpuUsagePercent,omitempty"`
|
||||
Memory MemoryMetric `json:"memory,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryMetric captures memory usage statistics in bytes.
|
||||
type MemoryMetric struct {
|
||||
TotalBytes int64 `json:"totalBytes,omitempty"`
|
||||
UsedBytes int64 `json:"usedBytes,omitempty"`
|
||||
FreeBytes int64 `json:"freeBytes,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
SwapTotal int64 `json:"swapTotalBytes,omitempty"`
|
||||
SwapUsed int64 `json:"swapUsedBytes,omitempty"`
|
||||
}
|
||||
|
||||
// Disk represents disk utilisation metrics.
|
||||
type Disk struct {
|
||||
Device string `json:"device,omitempty"`
|
||||
Mountpoint string `json:"mountpoint,omitempty"`
|
||||
Filesystem string `json:"filesystem,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
TotalBytes int64 `json:"totalBytes,omitempty"`
|
||||
UsedBytes int64 `json:"usedBytes,omitempty"`
|
||||
FreeBytes int64 `json:"freeBytes,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkInterface summarises network adapter statistics.
|
||||
type NetworkInterface struct {
|
||||
Name string `json:"name"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
Addresses []string `json:"addresses,omitempty"`
|
||||
RXBytes uint64 `json:"rxBytes,omitempty"`
|
||||
TXBytes uint64 `json:"txBytes,omitempty"`
|
||||
SpeedMbps *int64 `json:"speedMbps,omitempty"`
|
||||
}
|
||||
|
||||
// Sensors captures optional sensor readings reported by the agent.
|
||||
type Sensors struct {
|
||||
TemperatureCelsius map[string]float64 `json:"temperatureCelsius,omitempty"`
|
||||
FanRPM map[string]float64 `json:"fanRpm,omitempty"`
|
||||
Additional map[string]float64 `json:"additional,omitempty"`
|
||||
}
|
||||
|
|
@ -66,6 +66,13 @@ for build_name in "${!builds[@]}"; do
|
|||
-o "$BUILD_DIR/pulse-docker-agent-$build_name" \
|
||||
./cmd/pulse-docker-agent
|
||||
|
||||
# Build host agent binary
|
||||
env $build_env go build \
|
||||
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \
|
||||
-trimpath \
|
||||
-o "$BUILD_DIR/pulse-host-agent-$build_name" \
|
||||
./cmd/pulse-host-agent
|
||||
|
||||
# Build temperature proxy binary
|
||||
env $build_env go build \
|
||||
-ldflags="-s -w -X main.Version=v${VERSION} -X main.BuildTime=${build_time} -X main.GitCommit=${git_commit}" \
|
||||
|
|
@ -85,6 +92,7 @@ for build_name in "${!builds[@]}"; do
|
|||
# Copy binaries and VERSION file
|
||||
cp "$BUILD_DIR/pulse-$build_name" "$staging_dir/bin/pulse"
|
||||
cp "$BUILD_DIR/pulse-docker-agent-$build_name" "$staging_dir/bin/pulse-docker-agent"
|
||||
cp "$BUILD_DIR/pulse-host-agent-$build_name" "$staging_dir/bin/pulse-host-agent"
|
||||
cp "$BUILD_DIR/pulse-sensor-proxy-$build_name" "$staging_dir/bin/pulse-sensor-proxy"
|
||||
cp "scripts/install-docker-agent.sh" "$staging_dir/scripts/install-docker-agent.sh"
|
||||
chmod 755 "$staging_dir/scripts/install-docker-agent.sh"
|
||||
|
|
@ -112,6 +120,7 @@ mkdir -p "$universal_dir/scripts"
|
|||
for build_name in "${!builds[@]}"; do
|
||||
cp "$BUILD_DIR/pulse-$build_name" "$universal_dir/bin/pulse-${build_name}"
|
||||
cp "$BUILD_DIR/pulse-docker-agent-$build_name" "$universal_dir/bin/pulse-docker-agent-${build_name}"
|
||||
cp "$BUILD_DIR/pulse-host-agent-$build_name" "$universal_dir/bin/pulse-host-agent-${build_name}"
|
||||
cp "$BUILD_DIR/pulse-sensor-proxy-$build_name" "$universal_dir/bin/pulse-sensor-proxy-${build_name}"
|
||||
done
|
||||
|
||||
|
|
@ -188,9 +197,43 @@ esac
|
|||
EOF
|
||||
chmod +x "$universal_dir/bin/pulse-sensor-proxy"
|
||||
|
||||
cat > "$universal_dir/bin/pulse-host-agent" << 'EOF'
|
||||
#!/bin/sh
|
||||
# Auto-detect architecture and run appropriate pulse-host-agent binary
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|amd64)
|
||||
exec "$(dirname "$0")/pulse-host-agent-linux-amd64" "$@"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
exec "$(dirname "$0")/pulse-host-agent-linux-arm64" "$@"
|
||||
;;
|
||||
armv7l|armhf)
|
||||
exec "$(dirname "$0")/pulse-host-agent-linux-armv7" "$@"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$universal_dir/bin/pulse-host-agent"
|
||||
|
||||
# Add VERSION file
|
||||
echo "$VERSION" > "$universal_dir/VERSION"
|
||||
|
||||
# Build host agent for macOS arm64
|
||||
echo "Building host agent for macOS arm64..."
|
||||
env GOOS=darwin GOARCH=arm64 go build \
|
||||
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \
|
||||
-trimpath \
|
||||
-o "$BUILD_DIR/pulse-host-agent-darwin-arm64" \
|
||||
./cmd/pulse-host-agent
|
||||
|
||||
# Package macOS host agent
|
||||
tar -czf "$RELEASE_DIR/pulse-host-agent-v${VERSION}-darwin-arm64.tar.gz" -C "$BUILD_DIR" pulse-host-agent-darwin-arm64
|
||||
|
||||
# Create universal tarball
|
||||
cd "$universal_dir"
|
||||
tar -czf "../../$RELEASE_DIR/pulse-v${VERSION}.tar.gz" .
|
||||
|
|
|
|||
Loading…
Reference in a new issue