Add Servers & Tasks module ported from Schedule Task Manager
Ports cron/systemd task tracking across Debian/Raspbian servers, including the Linux push agent (install/report/uninstall scripts, rebranded from "schedule-task-manager-agent" to "homelab-manager-agent") and the manual-task-entry flow for things an agent can't see (Docker jobs, backups). The schema (servers/scheduled_tasks tables) was already in place from the foundation pass, so this is mostly a straight port of the original's services/routes. Deliberate change from the original: server/token management (which mints agent credentials) is now admin-only rather than open to any logged-in user, and manual task CRUD is gated to operator+ — consistent with how Secrets, IPAM, and DNS already split "configure credentials" from "everyday edits" across roles. All mutations are audit-logged. - server/src/services/tokens.ts, taskSync.ts: ported near-verbatim (agent token hashing, the agent-sync-marks-missing-as-stale-not-deleted logic). - server/src/routes/servers.ts, tasks.ts, agentReport.ts: same contract as the original (agent auth is a per-server bearer token, independent of the session-based requireAuth used everywhere else). - web: a single Servers & Tasks page (filter bar, task table grouped by server/schedule type, manual task form, and an admin-only server management panel with token reveal + copyable install/uninstall commands), replacing the original's two separate pages/apps. Verified: full build passes; a scripted HTTP test against a running server covers unauthenticated access, role gating at each tier (admin-only server mgmt, operator+ task mgmt), agent bearer-token auth (valid/invalid/rotated), manual-vs-agent task edit protection, stale-marking on re-sync, and cascade delete — 22/22 checks passing. Real agent installation on an actual Debian/Raspbian host still needs to be tried on the user's network. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac3feb935d
commit
9712d611a6
17 files changed
+1616
-1
No files matched your search
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Installs the Homelab Manager task-reporting agent as a systemd timer.
|
||||||
|
#
|
||||||
|
# Usage (must run as root — if not already root, put sudo right after the
|
||||||
|
# pipe so it applies to bash, not to curl):
|
||||||
|
# curl -fsSL https://homelab.example.lan/agent/linux/install.sh | \
|
||||||
|
# sudo API_URL=https://homelab.example.lan API_TOKEN=hlm_xxx bash
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${API_URL:?Set API_URL to your Homelab Manager URL, e.g. https://homelab.example.lan}"
|
||||||
|
: "${API_TOKEN:?Set API_TOKEN to the per-server token generated on the Servers & Tasks page}"
|
||||||
|
|
||||||
|
INSTALL_DIR="/usr/local/bin"
|
||||||
|
CONFIG_DIR="/etc"
|
||||||
|
SYSTEMD_DIR="/etc/systemd/system"
|
||||||
|
INTERVAL_MINUTES="${INTERVAL_MINUTES:-15}"
|
||||||
|
|
||||||
|
if [[ "$EUID" -ne 0 ]]; then
|
||||||
|
echo "This installer must be run as root (it installs a systemd timer)." >&2
|
||||||
|
echo "If you're piping from curl, put sudo right after the pipe so it elevates bash, not curl:" >&2
|
||||||
|
echo " curl -fsSL ... | sudo API_URL=... API_TOKEN=... bash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
suggest_package_install() {
|
||||||
|
local pkg="$1"
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
echo " sudo apt-get update && sudo apt-get install -y $pkg"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
echo " sudo dnf install -y $pkg"
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
echo " sudo yum install -y $pkg"
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
echo " sudo apk add $pkg"
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
echo " sudo pacman -S $pkg"
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then
|
||||||
|
echo " sudo zypper install -y $pkg"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
for bin in curl jq; do
|
||||||
|
if ! command -v "$bin" >/dev/null 2>&1; then
|
||||||
|
echo "Required dependency '$bin' is not installed. Try:" >&2
|
||||||
|
suggest_package_install "$bin" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! command -v systemctl >/dev/null 2>&1; then
|
||||||
|
echo "systemctl was not found — this installer requires a systemd-based Linux distribution." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing Homelab Manager agent from $API_URL ..."
|
||||||
|
|
||||||
|
curl -fsSL "$API_URL/agent/linux/report-tasks.sh" -o "$INSTALL_DIR/homelab-manager-agent.sh"
|
||||||
|
chmod 755 "$INSTALL_DIR/homelab-manager-agent.sh"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
cat > "$CONFIG_DIR/homelab-manager-agent.env" <<EOF
|
||||||
|
API_URL=$API_URL
|
||||||
|
API_TOKEN=$API_TOKEN
|
||||||
|
EOF
|
||||||
|
chmod 600 "$CONFIG_DIR/homelab-manager-agent.env"
|
||||||
|
|
||||||
|
cat > "$SYSTEMD_DIR/homelab-manager-agent.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Report scheduled tasks to Homelab Manager
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
EnvironmentFile=$CONFIG_DIR/homelab-manager-agent.env
|
||||||
|
ExecStart=$INSTALL_DIR/homelab-manager-agent.sh
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$SYSTEMD_DIR/homelab-manager-agent.timer" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Periodically report scheduled tasks to Homelab Manager
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=${INTERVAL_MINUTES}min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now homelab-manager-agent.timer
|
||||||
|
|
||||||
|
echo "Installed. Running an initial report now..."
|
||||||
|
"$INSTALL_DIR/homelab-manager-agent.sh"
|
||||||
|
|
||||||
|
echo "Done. The agent reports every ${INTERVAL_MINUTES} minute(s) via the 'homelab-manager-agent.timer' systemd timer."
|
||||||
|
echo "To remove it later: curl -fsSL $API_URL/agent/linux/uninstall.sh | sudo bash"
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Collects cron jobs and systemd timers on this host and POSTs them to the
|
||||||
|
# Homelab Manager API. Intended to run as root via a periodic systemd timer
|
||||||
|
# (see install.sh) but can be run manually for testing:
|
||||||
|
#
|
||||||
|
# API_URL=https://homelab.example.lan API_TOKEN=hlm_xxx ./report-tasks.sh --dry-run
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV_FILE="${ENV_FILE:-/etc/homelab-manager-agent.env}"
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
API_URL="${API_URL:-}"
|
||||||
|
API_TOKEN="${API_TOKEN:-}"
|
||||||
|
DRY_RUN=0
|
||||||
|
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
|
||||||
|
|
||||||
|
if [[ -z "$API_URL" || -z "$API_TOKEN" ]]; then
|
||||||
|
echo "API_URL and API_TOKEN must be set (env vars or $ENV_FILE)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
suggest_package_install() {
|
||||||
|
local pkg="$1"
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
echo " sudo apt-get update && sudo apt-get install -y $pkg"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
echo " sudo dnf install -y $pkg"
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
echo " sudo yum install -y $pkg"
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
echo " sudo apk add $pkg"
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
echo " sudo pacman -S $pkg"
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then
|
||||||
|
echo " sudo zypper install -y $pkg"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
for bin in curl jq; do
|
||||||
|
if ! command -v "$bin" >/dev/null 2>&1; then
|
||||||
|
echo "Required dependency '$bin' is not installed. Try:" >&2
|
||||||
|
suggest_package_install "$bin" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
TASKS_JSON="[]"
|
||||||
|
|
||||||
|
add_task() {
|
||||||
|
local schedule_type="$1" name="$2" command="$3" schedule_expression="$4" source="$5" enabled="$6"
|
||||||
|
TASKS_JSON=$(jq -c \
|
||||||
|
--arg schedule_type "$schedule_type" \
|
||||||
|
--arg name "$name" \
|
||||||
|
--arg command "$command" \
|
||||||
|
--arg schedule_expression "$schedule_expression" \
|
||||||
|
--arg source "$source" \
|
||||||
|
--argjson enabled "$enabled" \
|
||||||
|
'. + [{
|
||||||
|
schedule_type: $schedule_type,
|
||||||
|
name: $name,
|
||||||
|
command: $command,
|
||||||
|
schedule_expression: $schedule_expression,
|
||||||
|
source: $source,
|
||||||
|
enabled: $enabled
|
||||||
|
}]' <<<"$TASKS_JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
add_task_with_next_run() {
|
||||||
|
local schedule_type="$1" name="$2" command="$3" schedule_expression="$4" source="$5" enabled="$6" next_run_at="$7"
|
||||||
|
TASKS_JSON=$(jq -c \
|
||||||
|
--arg schedule_type "$schedule_type" \
|
||||||
|
--arg name "$name" \
|
||||||
|
--arg command "$command" \
|
||||||
|
--arg schedule_expression "$schedule_expression" \
|
||||||
|
--arg source "$source" \
|
||||||
|
--argjson enabled "$enabled" \
|
||||||
|
--arg next_run_at "$next_run_at" \
|
||||||
|
'. + [{
|
||||||
|
schedule_type: $schedule_type,
|
||||||
|
name: $name,
|
||||||
|
command: $command,
|
||||||
|
schedule_expression: $schedule_expression,
|
||||||
|
source: $source,
|
||||||
|
enabled: $enabled,
|
||||||
|
next_run_at: $next_run_at
|
||||||
|
}]' <<<"$TASKS_JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_crontab_lines() {
|
||||||
|
# Reads 5-field-schedule + command cron lines from stdin.
|
||||||
|
# $1 = source label, $2 = "system" (7-field, includes a user column) or "user" (6-field)
|
||||||
|
local source="$1" mode="$2"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
line="${line%%$'\r'}"
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
[[ "$line" =~ ^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*= ]] && continue
|
||||||
|
|
||||||
|
if [[ "$mode" == "system" ]]; then
|
||||||
|
# min hour dom mon dow user command...
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+[^[:space:]]+[[:space:]]+(.+)$ ]]; then
|
||||||
|
local sched="${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]} ${BASH_REMATCH[4]} ${BASH_REMATCH[5]}"
|
||||||
|
local cmd="${BASH_REMATCH[6]}"
|
||||||
|
add_task "cron" "$cmd" "$cmd" "$sched" "$source" true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# min hour dom mon dow command...
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+(.+)$ ]]; then
|
||||||
|
local sched="${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]} ${BASH_REMATCH[4]} ${BASH_REMATCH[5]}"
|
||||||
|
local cmd="${BASH_REMATCH[6]}"
|
||||||
|
add_task "cron" "$cmd" "$cmd" "$sched" "$source" true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_cron() {
|
||||||
|
[[ -r /etc/crontab ]] && parse_crontab_lines "/etc/crontab" "system" < /etc/crontab
|
||||||
|
|
||||||
|
if [[ -d /etc/cron.d ]]; then
|
||||||
|
for f in /etc/cron.d/*; do
|
||||||
|
[[ -f "$f" && -r "$f" ]] || continue
|
||||||
|
parse_crontab_lines "$f" "system" < "$f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v crontab >/dev/null 2>&1 && [[ -r /etc/passwd ]]; then
|
||||||
|
while IFS=: read -r user _ uid _ _ _ shell; do
|
||||||
|
case "$shell" in
|
||||||
|
*/nologin|*/false|"") continue ;;
|
||||||
|
esac
|
||||||
|
[[ "$uid" -lt 1000 && "$uid" != "0" ]] && continue
|
||||||
|
local_crontab=$(crontab -l -u "$user" 2>/dev/null || true)
|
||||||
|
[[ -z "$local_crontab" ]] && continue
|
||||||
|
parse_crontab_lines "crontab:$user" "user" <<<"$local_crontab"
|
||||||
|
done < /etc/passwd
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_systemd_timers() {
|
||||||
|
command -v systemctl >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
|
local timers_json
|
||||||
|
timers_json=$(systemctl list-timers --all --output=json 2>/dev/null || echo "[]")
|
||||||
|
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
local unit activates next_usec enabled_state schedule_expr next_run_at
|
||||||
|
unit=$(jq -r '.unit' <<<"$entry")
|
||||||
|
activates=$(jq -r '.activates // ""' <<<"$entry")
|
||||||
|
next_usec=$(jq -r '.next // 0' <<<"$entry")
|
||||||
|
|
||||||
|
enabled_state=$(systemctl is-enabled "$unit" 2>/dev/null || echo "unknown")
|
||||||
|
local enabled_bool="false"
|
||||||
|
[[ "$enabled_state" == "enabled" || "$enabled_state" == "static" ]] && enabled_bool="true"
|
||||||
|
|
||||||
|
schedule_expr=$(systemctl show "$unit" -p TimersCalendar --value 2>/dev/null | sed -n 's/.*OnCalendar=\([^;]*\);.*/\1/p' | sed 's/[[:space:]]*$//')
|
||||||
|
[[ -z "$schedule_expr" ]] && schedule_expr="(see: systemctl status $unit)"
|
||||||
|
|
||||||
|
next_run_at=""
|
||||||
|
if [[ "$next_usec" =~ ^[0-9]+$ && "$next_usec" -gt 0 ]]; then
|
||||||
|
next_run_at=$(date -u -d "@$((next_usec / 1000000))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$next_run_at" ]]; then
|
||||||
|
add_task_with_next_run "systemd_timer" "$unit" "$activates" "$schedule_expr" "$unit" "$enabled_bool" "$next_run_at"
|
||||||
|
else
|
||||||
|
add_task "systemd_timer" "$unit" "$activates" "$schedule_expr" "$unit" "$enabled_bool"
|
||||||
|
fi
|
||||||
|
done < <(jq -c '.[]' <<<"$timers_json")
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_cron
|
||||||
|
collect_systemd_timers
|
||||||
|
|
||||||
|
HOSTNAME_VALUE=$(hostname -f 2>/dev/null || hostname)
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg hostname "$HOSTNAME_VALUE" \
|
||||||
|
--arg os_type "linux" \
|
||||||
|
--arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||||
|
--argjson tasks "$TASKS_JSON" \
|
||||||
|
'{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, tasks: $tasks}')
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == "1" ]]; then
|
||||||
|
echo "$PAYLOAD" | jq .
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
response=$(curl -sS -o /tmp/hlm-agent-response.json -w "%{http_code}" \
|
||||||
|
-X POST "$API_URL/api/agent/report" \
|
||||||
|
-H "Authorization: Bearer $API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD")
|
||||||
|
|
||||||
|
if [[ "$response" -lt 200 || "$response" -ge 300 ]]; then
|
||||||
|
echo "Report failed with HTTP $response:" >&2
|
||||||
|
cat /tmp/hlm-agent-response.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Reported $(jq 'length' <<<"$TASKS_JSON") task(s) successfully."
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Removes the Homelab Manager task-reporting agent from this server: stops
|
||||||
|
# and deletes its systemd timer/service, the installed script, and its
|
||||||
|
# credentials file.
|
||||||
|
#
|
||||||
|
# Usage (must run as root — if not already root, put sudo right after the
|
||||||
|
# pipe so it applies to bash, not to curl):
|
||||||
|
# curl -fsSL https://homelab.example.lan/agent/linux/uninstall.sh | sudo bash
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/usr/local/bin"
|
||||||
|
CONFIG_DIR="/etc"
|
||||||
|
SYSTEMD_DIR="/etc/systemd/system"
|
||||||
|
|
||||||
|
if [[ "$EUID" -ne 0 ]]; then
|
||||||
|
echo "This uninstaller must be run as root." >&2
|
||||||
|
echo "If you're piping from curl, put sudo right after the pipe so it elevates bash, not curl:" >&2
|
||||||
|
echo " curl -fsSL ... | sudo bash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Removing Homelab Manager agent..."
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl disable --now homelab-manager-agent.timer >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f \
|
||||||
|
"$SYSTEMD_DIR/homelab-manager-agent.timer" \
|
||||||
|
"$SYSTEMD_DIR/homelab-manager-agent.service" \
|
||||||
|
"$CONFIG_DIR/homelab-manager-agent.env" \
|
||||||
|
"$INSTALL_DIR/homelab-manager-agent.sh" \
|
||||||
|
/tmp/hlm-agent-response.json
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done. The agent no longer runs or reports from this server."
|
||||||
|
echo "Its entry (and task history) in Homelab Manager is untouched —" \
|
||||||
|
"delete it from the Servers & Tasks page if you no longer want it tracked."
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Windows agent (planned, not yet implemented)
|
||||||
|
|
||||||
|
v1 of the Servers & Tasks module only supports Linux servers (cron + systemd
|
||||||
|
timers) — this covers the Debian and Raspbian hosts in the homelab. A Windows
|
||||||
|
agent is a natural future addition and would follow the same contract as the
|
||||||
|
Linux agent in [`../linux/report-tasks.sh`](../linux/report-tasks.sh):
|
||||||
|
|
||||||
|
- Collect tasks with `Get-ScheduledTask | Get-ScheduledTaskInfo` (name, action/command,
|
||||||
|
trigger description, next run time, enabled state).
|
||||||
|
- POST the same JSON shape to `POST /api/agent/report` with `schedule_type: "windows_task"`
|
||||||
|
(the server and UI already treat `schedule_type` as an open string in storage; only the
|
||||||
|
`tasks` API and UI schedule-type filter would need the new value added).
|
||||||
|
- Ship as a scheduled task (naturally) or a small Windows service that runs on a timer,
|
||||||
|
configured via the same `API_URL` / `API_TOKEN` environment variables as the Linux agent.
|
||||||
Generated
+32
@@ -2343,6 +2343,27 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "5.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.1.tgz",
|
||||||
|
"integrity": "sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cronstrue": {
|
||||||
|
"version": "2.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.59.0.tgz",
|
||||||
|
"integrity": "sha512-YKGmAy84hKH+hHIIER07VCAHf9u0Ldelx1uU6EBxsRPDXIA1m5fsKmJfyC3xBhw6cVC/1i83VdbL4PvepTrt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"cronstrue": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -3203,6 +3224,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -4803,6 +4833,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tabler/core": "^1.5.1",
|
"@tabler/core": "^1.5.1",
|
||||||
"@tabler/icons-react": "^3.46.0",
|
"@tabler/icons-react": "^3.46.0",
|
||||||
|
"cron-parser": "^5.10.0",
|
||||||
|
"cronstrue": "^2.53.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import { auditLogRouter } from "./routes/auditLog.js";
|
|||||||
import { secretsRouter } from "./routes/secrets.js";
|
import { secretsRouter } from "./routes/secrets.js";
|
||||||
import { ipamRouter } from "./routes/ipam.js";
|
import { ipamRouter } from "./routes/ipam.js";
|
||||||
import { dnsRouter } from "./routes/dns.js";
|
import { dnsRouter } from "./routes/dns.js";
|
||||||
|
import { serversRouter } from "./routes/servers.js";
|
||||||
|
import { tasksRouter } from "./routes/tasks.js";
|
||||||
|
import { agentReportRouter } from "./routes/agentReport.js";
|
||||||
|
|
||||||
warnIfAuthNotConfigured();
|
warnIfAuthNotConfigured();
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
@@ -59,6 +62,9 @@ app.use("/api/audit-log", auditLogRouter);
|
|||||||
app.use("/api/secrets", secretsRouter);
|
app.use("/api/secrets", secretsRouter);
|
||||||
app.use("/api/ipam", ipamRouter);
|
app.use("/api/ipam", ipamRouter);
|
||||||
app.use("/api/dns", dnsRouter);
|
app.use("/api/dns", dnsRouter);
|
||||||
|
app.use("/api/servers", serversRouter);
|
||||||
|
app.use("/api/tasks", tasksRouter);
|
||||||
|
app.use("/api/agent/report", agentReportRouter);
|
||||||
|
|
||||||
if (existsSync(webDist)) {
|
if (existsSync(webDist)) {
|
||||||
app.use(express.static(webDist));
|
app.use(express.static(webDist));
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { hashToken } from "../services/tokens.js";
|
||||||
|
import { syncServerTasks } from "../services/taskSync.js";
|
||||||
|
|
||||||
|
export const agentReportRouter = Router();
|
||||||
|
|
||||||
|
const reportSchema = z.object({
|
||||||
|
hostname: z.string().max(255).optional(),
|
||||||
|
os_type: z.string().optional(),
|
||||||
|
reported_at: z.string().optional(),
|
||||||
|
tasks: z.array(
|
||||||
|
z.object({
|
||||||
|
schedule_type: z.enum(["cron", "systemd_timer"]),
|
||||||
|
name: z.string().min(1),
|
||||||
|
command: z.string().optional(),
|
||||||
|
schedule_expression: z.string().optional(),
|
||||||
|
source: z.string().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
next_run_at: z.string().optional(),
|
||||||
|
metadata: z.unknown().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
agentReportRouter.post("/", async (req, res) => {
|
||||||
|
const authHeader = req.header("authorization") ?? "";
|
||||||
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (!match) {
|
||||||
|
return res.status(401).json({ error: "missing_token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = hashToken(match[1]);
|
||||||
|
const server = await db.query.servers.findFirst({
|
||||||
|
where: (s, { eq }) => eq(s.apiTokenHash, tokenHash),
|
||||||
|
});
|
||||||
|
if (!server) {
|
||||||
|
return res.status(401).json({ error: "invalid_token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = reportSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncServerTasks(server.id, {
|
||||||
|
hostname: parsed.data.hostname,
|
||||||
|
tasks: parsed.data.tasks.map((t) => ({
|
||||||
|
scheduleType: t.schedule_type,
|
||||||
|
name: t.name,
|
||||||
|
command: t.command,
|
||||||
|
scheduleExpression: t.schedule_expression,
|
||||||
|
source: t.source,
|
||||||
|
enabled: t.enabled,
|
||||||
|
nextRunAt: t.next_run_at,
|
||||||
|
metadata: t.metadata,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(202).json({ ok: true, taskCount: parsed.data.tasks.length });
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { servers } from "../db/schema.js";
|
||||||
|
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||||
|
import { generateApiToken } from "../services/tokens.js";
|
||||||
|
import { recordAudit } from "../services/audit.js";
|
||||||
|
|
||||||
|
export const serversRouter = Router();
|
||||||
|
serversRouter.use(requireAuth);
|
||||||
|
|
||||||
|
const createServerSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
hostname: z.string().max(255).optional(),
|
||||||
|
osType: z.literal("linux").default("linux"),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
serversRouter.get("/", async (_req, res) => {
|
||||||
|
const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] });
|
||||||
|
res.json({
|
||||||
|
servers: rows.map(({ apiTokenHash, ...rest }) => rest),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
serversRouter.post("/", requireRole("admin"), async (req, res) => {
|
||||||
|
const parsed = createServerSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, prefix, hash } = generateApiToken();
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(servers)
|
||||||
|
.values({
|
||||||
|
name: parsed.data.name,
|
||||||
|
hostname: parsed.data.hostname,
|
||||||
|
osType: parsed.data.osType,
|
||||||
|
description: parsed.data.description,
|
||||||
|
apiTokenHash: hash,
|
||||||
|
apiTokenPrefix: prefix,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
actor: req.currentUser!,
|
||||||
|
category: "server",
|
||||||
|
action: "create",
|
||||||
|
targetType: "server",
|
||||||
|
targetId: created.id,
|
||||||
|
detail: { name: created.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiTokenHash, ...serverOut } = created;
|
||||||
|
// The full token is only ever shown once, at creation time.
|
||||||
|
res.status(201).json({ server: serverOut, token });
|
||||||
|
});
|
||||||
|
|
||||||
|
serversRouter.post("/:id/rotate-token", requireRole("admin"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||||
|
|
||||||
|
const { token, prefix, hash } = generateApiToken();
|
||||||
|
const [updated] = await db
|
||||||
|
.update(servers)
|
||||||
|
.set({ apiTokenHash: hash, apiTokenPrefix: prefix })
|
||||||
|
.where(eq(servers.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) return res.status(404).json({ error: "not_found" });
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
actor: req.currentUser!,
|
||||||
|
category: "server",
|
||||||
|
action: "rotate_token",
|
||||||
|
targetType: "server",
|
||||||
|
targetId: id,
|
||||||
|
detail: { name: updated.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiTokenHash, ...serverOut } = updated;
|
||||||
|
res.json({ server: serverOut, token });
|
||||||
|
});
|
||||||
|
|
||||||
|
serversRouter.delete("/:id", requireRole("admin"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||||
|
|
||||||
|
const deleted = await db.delete(servers).where(eq(servers.id, id)).returning();
|
||||||
|
if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
actor: req.currentUser!,
|
||||||
|
category: "server",
|
||||||
|
action: "delete",
|
||||||
|
targetType: "server",
|
||||||
|
targetId: id,
|
||||||
|
detail: { name: deleted[0].name },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { and, eq, like, or } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { scheduledTasks, servers } from "../db/schema.js";
|
||||||
|
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||||
|
import { recordAudit } from "../services/audit.js";
|
||||||
|
|
||||||
|
export const tasksRouter = Router();
|
||||||
|
tasksRouter.use(requireAuth);
|
||||||
|
|
||||||
|
const TASK_COLUMNS = {
|
||||||
|
id: scheduledTasks.id,
|
||||||
|
serverId: scheduledTasks.serverId,
|
||||||
|
serverName: servers.name,
|
||||||
|
scheduleType: scheduledTasks.scheduleType,
|
||||||
|
origin: scheduledTasks.origin,
|
||||||
|
name: scheduledTasks.name,
|
||||||
|
command: scheduledTasks.command,
|
||||||
|
scheduleExpression: scheduledTasks.scheduleExpression,
|
||||||
|
source: scheduledTasks.source,
|
||||||
|
enabled: scheduledTasks.enabled,
|
||||||
|
nextRunAt: scheduledTasks.nextRunAt,
|
||||||
|
isStale: scheduledTasks.isStale,
|
||||||
|
firstSeenAt: scheduledTasks.firstSeenAt,
|
||||||
|
lastSeenAt: scheduledTasks.lastSeenAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
tasksRouter.get("/", async (req, res) => {
|
||||||
|
const serverId = req.query.serverId ? Number(req.query.serverId) : undefined;
|
||||||
|
const scheduleType = typeof req.query.scheduleType === "string" ? req.query.scheduleType : undefined;
|
||||||
|
const search = typeof req.query.search === "string" ? req.query.search.trim() : undefined;
|
||||||
|
const includeStale = req.query.includeStale === "true";
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (serverId && Number.isInteger(serverId)) {
|
||||||
|
conditions.push(eq(scheduledTasks.serverId, serverId));
|
||||||
|
}
|
||||||
|
if (scheduleType) {
|
||||||
|
conditions.push(eq(scheduledTasks.scheduleType, scheduleType));
|
||||||
|
}
|
||||||
|
if (!includeStale) {
|
||||||
|
conditions.push(eq(scheduledTasks.isStale, false));
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
conditions.push(or(like(scheduledTasks.name, pattern), like(scheduledTasks.command, pattern)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select(TASK_COLUMNS)
|
||||||
|
.from(scheduledTasks)
|
||||||
|
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(servers.name, scheduledTasks.scheduleType, scheduledTasks.name);
|
||||||
|
|
||||||
|
res.json({ tasks: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
const manualTaskSchema = z.object({
|
||||||
|
serverId: z.number().int(),
|
||||||
|
scheduleType: z.enum(["cron", "systemd_timer", "docker", "backup", "update", "n8n_workflow", "manual"]),
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
command: z.string().max(1000).optional(),
|
||||||
|
scheduleExpression: z.string().max(200).optional(),
|
||||||
|
nextRunAt: z.string().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
tasksRouter.post("/", requireRole("operator"), async (req, res) => {
|
||||||
|
const parsed = manualTaskSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await db.query.servers.findFirst({ where: eq(servers.id, parsed.data.serverId) });
|
||||||
|
if (!server) {
|
||||||
|
return res.status(400).json({ error: "unknown_server" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const [created] = await db
|
||||||
|
.insert(scheduledTasks)
|
||||||
|
.values({
|
||||||
|
serverId: parsed.data.serverId,
|
||||||
|
scheduleType: parsed.data.scheduleType,
|
||||||
|
origin: "manual",
|
||||||
|
name: parsed.data.name,
|
||||||
|
command: parsed.data.command,
|
||||||
|
scheduleExpression: parsed.data.scheduleExpression,
|
||||||
|
nextRunAt: parsed.data.nextRunAt,
|
||||||
|
enabled: parsed.data.enabled ?? true,
|
||||||
|
firstSeenAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
})
|
||||||
|
.returning({ id: scheduledTasks.id });
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
actor: req.currentUser!,
|
||||||
|
category: "task",
|
||||||
|
action: "create",
|
||||||
|
targetType: "scheduled_task",
|
||||||
|
targetId: created.id,
|
||||||
|
detail: { name: parsed.data.name, serverId: parsed.data.serverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [task] = await db
|
||||||
|
.select(TASK_COLUMNS)
|
||||||
|
.from(scheduledTasks)
|
||||||
|
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||||
|
.where(eq(scheduledTasks.id, created.id));
|
||||||
|
res.status(201).json({ task });
|
||||||
|
});
|
||||||
|
|
||||||
|
const manualTaskUpdateSchema = manualTaskSchema.omit({ serverId: true }).partial();
|
||||||
|
|
||||||
|
tasksRouter.patch("/:id", requireRole("operator"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||||
|
|
||||||
|
const parsed = manualTaskUpdateSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) });
|
||||||
|
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||||
|
if (existing.origin !== "manual") {
|
||||||
|
return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be edited." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(scheduledTasks)
|
||||||
|
.set({ ...parsed.data, lastSeenAt: new Date().toISOString() })
|
||||||
|
.where(eq(scheduledTasks.id, id));
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
actor: req.currentUser!,
|
||||||
|
category: "task",
|
||||||
|
action: "update",
|
||||||
|
targetType: "scheduled_task",
|
||||||
|
targetId: id,
|
||||||
|
detail: { name: existing.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [task] = await db
|
||||||
|
.select(TASK_COLUMNS)
|
||||||
|
.from(scheduledTasks)
|
||||||
|
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||||
|
.where(eq(scheduledTasks.id, id));
|
||||||
|
res.json({ task });
|
||||||
|
});
|
||||||
|
|
||||||
|
tasksRouter.delete("/:id", requireRole("operator"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||||
|
|
||||||
|
const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) });
|
||||||
|
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||||
|
if (existing.origin !== "manual") {
|
||||||
|
return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be deleted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(scheduledTasks).where(eq(scheduledTasks.id, id));
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
actor: req.currentUser!,
|
||||||
|
category: "task",
|
||||||
|
action: "delete",
|
||||||
|
targetType: "scheduled_task",
|
||||||
|
targetId: id,
|
||||||
|
detail: { name: existing.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { and, eq, notInArray } from "drizzle-orm";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { scheduledTasks, servers } from "../db/schema.js";
|
||||||
|
|
||||||
|
export interface IncomingTask {
|
||||||
|
scheduleType: "cron" | "systemd_timer";
|
||||||
|
name: string;
|
||||||
|
command?: string;
|
||||||
|
scheduleExpression?: string;
|
||||||
|
source?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
nextRunAt?: string;
|
||||||
|
metadata?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentReport {
|
||||||
|
hostname?: string;
|
||||||
|
tasks: IncomingTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncServerTasks(serverId: number, report: AgentReport) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const existing = await db.query.scheduledTasks.findMany({
|
||||||
|
where: and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const seenIds: number[] = [];
|
||||||
|
|
||||||
|
for (const task of report.tasks) {
|
||||||
|
const match = existing.find(
|
||||||
|
(t) =>
|
||||||
|
t.scheduleType === task.scheduleType &&
|
||||||
|
t.name === task.name &&
|
||||||
|
(t.source ?? "") === (task.source ?? ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(scheduledTasks)
|
||||||
|
.set({
|
||||||
|
command: task.command,
|
||||||
|
scheduleExpression: task.scheduleExpression,
|
||||||
|
enabled: task.enabled ?? true,
|
||||||
|
nextRunAt: task.nextRunAt,
|
||||||
|
rawMetadata: task.metadata ? JSON.stringify(task.metadata) : null,
|
||||||
|
isStale: false,
|
||||||
|
lastSeenAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(scheduledTasks.id, match.id))
|
||||||
|
.returning({ id: scheduledTasks.id });
|
||||||
|
seenIds.push(updated.id);
|
||||||
|
} else {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(scheduledTasks)
|
||||||
|
.values({
|
||||||
|
serverId,
|
||||||
|
scheduleType: task.scheduleType,
|
||||||
|
origin: "agent",
|
||||||
|
name: task.name,
|
||||||
|
command: task.command,
|
||||||
|
scheduleExpression: task.scheduleExpression,
|
||||||
|
source: task.source,
|
||||||
|
enabled: task.enabled ?? true,
|
||||||
|
nextRunAt: task.nextRunAt,
|
||||||
|
rawMetadata: task.metadata ? JSON.stringify(task.metadata) : null,
|
||||||
|
firstSeenAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
})
|
||||||
|
.returning({ id: scheduledTasks.id });
|
||||||
|
seenIds.push(created.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything agent-sourced for this server that wasn't in this report is now stale,
|
||||||
|
// rather than deleted, so a bad/partial agent run doesn't wipe history. Manually
|
||||||
|
// entered tasks (origin = 'manual') are never touched by agent sync.
|
||||||
|
const agentScope = and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent"));
|
||||||
|
if (seenIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(scheduledTasks)
|
||||||
|
.set({ isStale: true })
|
||||||
|
.where(and(agentScope, notInArray(scheduledTasks.id, seenIds)));
|
||||||
|
} else {
|
||||||
|
await db.update(scheduledTasks).set({ isStale: true }).where(agentScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(servers)
|
||||||
|
.set({ lastSeenAt: now, ...(report.hostname ? { hostname: report.hostname } : {}) })
|
||||||
|
.where(eq(servers.id, serverId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
const TOKEN_PREFIX_LENGTH = 8;
|
||||||
|
|
||||||
|
export function generateApiToken(): { token: string; prefix: string; hash: string } {
|
||||||
|
const token = `hlm_${randomBytes(24).toString("hex")}`;
|
||||||
|
const prefix = token.slice(0, TOKEN_PREFIX_LENGTH);
|
||||||
|
return { token, prefix, hash: hashToken(token) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokensMatch(hashA: string, hashB: string): boolean {
|
||||||
|
const bufA = Buffer.from(hashA, "hex");
|
||||||
|
const bufB = Buffer.from(hashB, "hex");
|
||||||
|
if (bufA.length !== bufB.length) return false;
|
||||||
|
return timingSafeEqual(bufA, bufB);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tabler/core": "^1.5.1",
|
"@tabler/core": "^1.5.1",
|
||||||
"@tabler/icons-react": "^3.46.0",
|
"@tabler/icons-react": "^3.46.0",
|
||||||
|
"cron-parser": "^5.10.0",
|
||||||
|
"cronstrue": "^2.53.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@ import AuditLog from "./pages/AuditLog";
|
|||||||
import Secrets from "./pages/Secrets";
|
import Secrets from "./pages/Secrets";
|
||||||
import Ipam from "./pages/Ipam";
|
import Ipam from "./pages/Ipam";
|
||||||
import Dns from "./pages/Dns";
|
import Dns from "./pages/Dns";
|
||||||
|
import ServersTasks from "./pages/ServersTasks";
|
||||||
import ComingSoon from "./pages/ComingSoon";
|
import ComingSoon from "./pages/ComingSoon";
|
||||||
import AppShell from "./layout/AppShell";
|
import AppShell from "./layout/AppShell";
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ export default function App() {
|
|||||||
<AppShell user={user}>
|
<AppShell user={user}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard user={user} />} />
|
<Route path="/" element={<Dashboard user={user} />} />
|
||||||
<Route path="/servers" element={<ComingSoon title="Servers & Tasks" />} />
|
<Route path="/servers" element={<ServersTasks user={user} />} />
|
||||||
<Route path="/dns" element={<Dns user={user} />} />
|
<Route path="/dns" element={<Dns user={user} />} />
|
||||||
<Route path="/ipam" element={<Ipam user={user} />} />
|
<Route path="/ipam" element={<Ipam user={user} />} />
|
||||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||||
|
|||||||
@@ -120,6 +120,46 @@ export interface DnsRecordInput {
|
|||||||
proxied?: boolean;
|
proxied?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerRecord {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
hostname: string | null;
|
||||||
|
osType: string;
|
||||||
|
description: string | null;
|
||||||
|
apiTokenPrefix: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual";
|
||||||
|
|
||||||
|
export interface TaskRecord {
|
||||||
|
id: number;
|
||||||
|
serverId: number;
|
||||||
|
serverName: string;
|
||||||
|
scheduleType: ScheduleType;
|
||||||
|
origin: "agent" | "manual";
|
||||||
|
name: string;
|
||||||
|
command: string | null;
|
||||||
|
scheduleExpression: string | null;
|
||||||
|
source: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
nextRunAt: string | null;
|
||||||
|
isStale: boolean;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualTaskInput {
|
||||||
|
serverId: number;
|
||||||
|
scheduleType: ScheduleType;
|
||||||
|
name: string;
|
||||||
|
command?: string;
|
||||||
|
scheduleExpression?: string;
|
||||||
|
nextRunAt?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class UnauthorizedError extends Error {}
|
export class UnauthorizedError extends Error {}
|
||||||
export class ForbiddenError extends Error {}
|
export class ForbiddenError extends Error {}
|
||||||
|
|
||||||
@@ -227,4 +267,35 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
servers: {
|
||||||
|
list: () => request<{ servers: ServerRecord[] }>("/api/servers"),
|
||||||
|
create: (data: { name: string; hostname?: string; description?: string }) =>
|
||||||
|
request<{ server: ServerRecord; token: string }>("/api/servers", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
rotateToken: (id: number) =>
|
||||||
|
request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
list: (
|
||||||
|
params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.serverId) qs.set("serverId", String(params.serverId));
|
||||||
|
if (params.scheduleType) qs.set("scheduleType", params.scheduleType);
|
||||||
|
if (params.search) qs.set("search", params.search);
|
||||||
|
if (params.includeStale) qs.set("includeStale", "true");
|
||||||
|
const query = qs.toString();
|
||||||
|
return request<{ tasks: TaskRecord[] }>(`/api/tasks${query ? `?${query}` : ""}`);
|
||||||
|
},
|
||||||
|
create: (data: ManualTaskInput) =>
|
||||||
|
request<{ task: TaskRecord }>("/api/tasks", { method: "POST", body: JSON.stringify(data) }),
|
||||||
|
update: (id: number, data: Partial<Omit<ManualTaskInput, "serverId">>) =>
|
||||||
|
request<{ task: TaskRecord }>(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
|
remove: (id: number) => request<void>(`/api/tasks/${id}`, { method: "DELETE" }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function CopyButton({ text }: { text: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API unavailable (e.g. insecure context) — nothing more we can do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={handleCopy}>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import cronstrue from "cronstrue";
|
||||||
|
import { CronExpressionParser } from "cron-parser";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type CurrentUser,
|
||||||
|
type ManualTaskInput,
|
||||||
|
type ScheduleType,
|
||||||
|
type ServerRecord,
|
||||||
|
type TaskRecord,
|
||||||
|
} from "../api/client";
|
||||||
|
import CopyButton from "../components/CopyButton";
|
||||||
|
import { formatDateTime } from "../utils/date";
|
||||||
|
|
||||||
|
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
cron: "Cron jobs",
|
||||||
|
systemd_timer: "systemd timers",
|
||||||
|
docker: "Docker jobs",
|
||||||
|
backup: "Backups",
|
||||||
|
update: "Updates",
|
||||||
|
n8n_workflow: "n8n workflows",
|
||||||
|
manual: "Other",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
|
||||||
|
{ value: "docker", label: "Docker" },
|
||||||
|
{ value: "cron", label: "Cron" },
|
||||||
|
{ value: "systemd_timer", label: "systemd timer" },
|
||||||
|
{ value: "backup", label: "Backup" },
|
||||||
|
{ value: "update", label: "Update" },
|
||||||
|
{ value: "n8n_workflow", label: "n8n workflow" },
|
||||||
|
{ value: "manual", label: "Other / manual" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function installCommand(token: string): string {
|
||||||
|
return `curl -fsSL ${window.location.origin}/agent/linux/install.sh | sudo API_URL=${window.location.origin} API_TOKEN=${token} bash`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstallCommand(): string {
|
||||||
|
return `curl -fsSL ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSchedule(task: TaskRecord): string {
|
||||||
|
if (!task.scheduleExpression) return "—";
|
||||||
|
if (task.scheduleType === "cron") {
|
||||||
|
try {
|
||||||
|
return cronstrue.toString(task.scheduleExpression, { verbose: false });
|
||||||
|
} catch {
|
||||||
|
return task.scheduleExpression;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return task.scheduleExpression;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NextRun {
|
||||||
|
date: Date;
|
||||||
|
estimated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeNextRun(task: TaskRecord): NextRun | null {
|
||||||
|
if (task.nextRunAt) return { date: new Date(task.nextRunAt), estimated: false };
|
||||||
|
if (task.scheduleType !== "cron" || !task.scheduleExpression || task.isStale || !task.enabled) return null;
|
||||||
|
try {
|
||||||
|
const date = CronExpressionParser.parse(task.scheduleExpression).next().toDate();
|
||||||
|
return { date, estimated: true };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyTaskForm = {
|
||||||
|
scheduleType: "docker" as ScheduleType,
|
||||||
|
name: "",
|
||||||
|
command: "",
|
||||||
|
scheduleExpression: "",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServersTasks({ user }: { user: CurrentUser }) {
|
||||||
|
const isAdmin = user.role === "admin";
|
||||||
|
const canEditTasks = user.role === "admin" || user.role === "operator";
|
||||||
|
|
||||||
|
const [servers, setServers] = useState<ServerRecord[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<TaskRecord[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [managingServers, setManagingServers] = useState(false);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
serverId: undefined as number | undefined,
|
||||||
|
scheduleType: undefined as string | undefined,
|
||||||
|
search: "",
|
||||||
|
includeStale: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [taskForm, setTaskForm] = useState<typeof emptyTaskForm | null>(null);
|
||||||
|
const [editingTask, setEditingTask] = useState<TaskRecord | null>(null);
|
||||||
|
const [newTaskServerId, setNewTaskServerId] = useState<number | undefined>(undefined);
|
||||||
|
const [savingTask, setSavingTask] = useState(false);
|
||||||
|
|
||||||
|
const [serverName, setServerName] = useState("");
|
||||||
|
const [serverHostname, setServerHostname] = useState("");
|
||||||
|
const [serverDescription, setServerDescription] = useState("");
|
||||||
|
const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null);
|
||||||
|
const [uninstallFor, setUninstallFor] = useState<ServerRecord | null>(null);
|
||||||
|
|
||||||
|
const loadServers = useCallback(() => {
|
||||||
|
return api.servers
|
||||||
|
.list()
|
||||||
|
.then((res) => setServers(res.servers))
|
||||||
|
.catch((err) => setError(String(err)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTasks = useCallback(() => {
|
||||||
|
return api.tasks
|
||||||
|
.list(filters)
|
||||||
|
.then((res) => setTasks(res.tasks))
|
||||||
|
.catch((err) => setError(String(err)));
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadServers();
|
||||||
|
}, [loadServers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(loadTasks, 200);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [loadTasks]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const byServer = new Map<number, { serverId: number; serverName: string; tasks: TaskRecord[] }>();
|
||||||
|
for (const task of tasks ?? []) {
|
||||||
|
const entry = byServer.get(task.serverId) ?? { serverId: task.serverId, serverName: task.serverName, tasks: [] };
|
||||||
|
entry.tasks.push(task);
|
||||||
|
byServer.set(task.serverId, entry);
|
||||||
|
}
|
||||||
|
return [...byServer.values()];
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
function openAddTask() {
|
||||||
|
setEditingTask(null);
|
||||||
|
setNewTaskServerId(filters.serverId ?? servers[0]?.id);
|
||||||
|
setTaskForm({ ...emptyTaskForm });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditTask(task: TaskRecord) {
|
||||||
|
setEditingTask(task);
|
||||||
|
setTaskForm({
|
||||||
|
scheduleType: task.scheduleType,
|
||||||
|
name: task.name,
|
||||||
|
command: task.command ?? "",
|
||||||
|
scheduleExpression: task.scheduleExpression ?? "",
|
||||||
|
enabled: task.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTask(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!taskForm) return;
|
||||||
|
setSavingTask(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const serverId = editingTask?.serverId ?? newTaskServerId;
|
||||||
|
if (!serverId) {
|
||||||
|
setError("Choose a server first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data: ManualTaskInput = {
|
||||||
|
serverId,
|
||||||
|
scheduleType: taskForm.scheduleType,
|
||||||
|
name: taskForm.name,
|
||||||
|
command: taskForm.command || undefined,
|
||||||
|
scheduleExpression: taskForm.scheduleExpression || undefined,
|
||||||
|
enabled: taskForm.enabled,
|
||||||
|
};
|
||||||
|
if (editingTask) {
|
||||||
|
await api.tasks.update(editingTask.id, data);
|
||||||
|
} else {
|
||||||
|
await api.tasks.create(data);
|
||||||
|
}
|
||||||
|
setTaskForm(null);
|
||||||
|
setEditingTask(null);
|
||||||
|
await loadTasks();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSavingTask(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(task: TaskRecord) {
|
||||||
|
if (!confirm(`Delete "${task.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await api.tasks.remove(task.id);
|
||||||
|
await loadTasks();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createServer(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.servers.create({
|
||||||
|
name: serverName,
|
||||||
|
hostname: serverHostname || undefined,
|
||||||
|
description: serverDescription || undefined,
|
||||||
|
});
|
||||||
|
setNewToken(result);
|
||||||
|
setServerName("");
|
||||||
|
setServerHostname("");
|
||||||
|
setServerDescription("");
|
||||||
|
await loadServers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateToken(id: number) {
|
||||||
|
try {
|
||||||
|
const result = await api.servers.rotateToken(id);
|
||||||
|
setNewToken(result);
|
||||||
|
await loadServers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServer(id: number) {
|
||||||
|
if (!confirm("Remove this server and all its tracked tasks?")) return;
|
||||||
|
try {
|
||||||
|
await api.servers.remove(id);
|
||||||
|
await loadServers();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="d-flex align-items-center mb-3">
|
||||||
|
<h2 className="page-title mb-0">Servers & Tasks</h2>
|
||||||
|
{isAdmin && (
|
||||||
|
<button className="btn btn-outline-secondary ms-auto" onClick={() => setManagingServers((v) => !v)}>
|
||||||
|
{managingServers ? "Back to tasks" : "Manage servers"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
|
||||||
|
{managingServers ? (
|
||||||
|
<>
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Add a server</h3>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={createServer}>
|
||||||
|
<div className="card-body row g-3">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label">Server name</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
placeholder="e.g. nas01"
|
||||||
|
value={serverName}
|
||||||
|
onChange={(e) => setServerName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label">Hostname</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="optional"
|
||||||
|
value={serverHostname}
|
||||||
|
onChange={(e) => setServerHostname(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label">Description</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="optional"
|
||||||
|
value={serverDescription}
|
||||||
|
onChange={(e) => setServerDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Add server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newToken && (
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Agent token for {newToken.server.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary">This token is only shown once — copy it now.</p>
|
||||||
|
<div className="input-group mb-3">
|
||||||
|
<code className="form-control">{newToken.token}</code>
|
||||||
|
<CopyButton text={newToken.token} />
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary">
|
||||||
|
Install the agent on the server (run as root — put sudo right after the pipe, not before curl):
|
||||||
|
</p>
|
||||||
|
<div className="input-group">
|
||||||
|
<pre className="form-control text-wrap mb-0">{installCommand(newToken.token)}</pre>
|
||||||
|
<CopyButton text={installCommand(newToken.token)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<button className="btn" onClick={() => setNewToken(null)}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uninstallFor && (
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Uninstall agent from {uninstallFor.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary">Run this on the server as root to stop and remove the agent (its entry here is kept):</p>
|
||||||
|
<div className="input-group">
|
||||||
|
<pre className="form-control text-wrap mb-0">{uninstallCommand()}</pre>
|
||||||
|
<CopyButton text={uninstallCommand()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<button className="btn" onClick={() => setUninstallFor(null)}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Hostname</th>
|
||||||
|
<th>Token</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th className="w-1">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>{s.name}</td>
|
||||||
|
<td>{s.hostname ?? "—"}</td>
|
||||||
|
<td className="text-secondary">{s.apiTokenPrefix}…</td>
|
||||||
|
<td>{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="btn-list flex-nowrap">
|
||||||
|
<button className="btn btn-sm" onClick={() => rotateToken(s.id)}>
|
||||||
|
Rotate token
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => setUninstallFor(s)}>
|
||||||
|
Uninstall
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm btn-outline-danger" onClick={() => deleteServer(s.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{servers.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-secondary text-center">
|
||||||
|
No servers registered yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row g-2 align-items-end">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">Server</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={filters.serverId ?? ""}
|
||||||
|
onChange={(e) => setFilters({ ...filters, serverId: e.target.value ? Number(e.target.value) : undefined })}
|
||||||
|
>
|
||||||
|
<option value="">All servers</option>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">Schedule type</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={filters.scheduleType ?? ""}
|
||||||
|
onChange={(e) => setFilters({ ...filters, scheduleType: e.target.value || undefined })}
|
||||||
|
>
|
||||||
|
<option value="">All schedule types</option>
|
||||||
|
{SCHEDULE_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">Search</label>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Name or command…"
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3 d-flex align-items-center gap-3">
|
||||||
|
<label className="form-check mb-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={filters.includeStale}
|
||||||
|
onChange={(e) => setFilters({ ...filters, includeStale: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Show stale/missing</span>
|
||||||
|
</label>
|
||||||
|
{canEditTasks && (
|
||||||
|
<button className="btn btn-primary ms-auto" onClick={openAddTask} disabled={servers.length === 0}>
|
||||||
|
Add task
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{taskForm && (
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">{editingTask ? "Edit task" : "Add a task manually"}</h3>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submitTask}>
|
||||||
|
<div className="card-body row g-3">
|
||||||
|
{!editingTask && (
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">Server</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
required
|
||||||
|
value={newTaskServerId ?? ""}
|
||||||
|
onChange={(e) => setNewTaskServerId(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
>
|
||||||
|
<option value="">Choose a server…</option>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label className="form-label">Schedule type</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={taskForm.scheduleType}
|
||||||
|
onChange={(e) => setTaskForm({ ...taskForm, scheduleType: e.target.value as ScheduleType })}
|
||||||
|
>
|
||||||
|
{SCHEDULE_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
placeholder="e.g. postgres-backup"
|
||||||
|
value={taskForm.name}
|
||||||
|
onChange={(e) => setTaskForm({ ...taskForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">Command / description</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="e.g. docker exec pg-backup /backup.sh"
|
||||||
|
value={taskForm.command}
|
||||||
|
onChange={(e) => setTaskForm({ ...taskForm, command: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">Schedule</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="e.g. daily at 02:00, or 0 2 * * *"
|
||||||
|
value={taskForm.scheduleExpression}
|
||||||
|
onChange={(e) => setTaskForm({ ...taskForm, scheduleExpression: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={taskForm.enabled}
|
||||||
|
onChange={(e) => setTaskForm({ ...taskForm, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={savingTask}>
|
||||||
|
{editingTask ? "Save changes" : "Add task"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
setTaskForm(null);
|
||||||
|
setEditingTask(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tasks && groups.length === 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-secondary">
|
||||||
|
No scheduled tasks found yet. Install the agent on a server to auto-report cron/systemd tasks, or use
|
||||||
|
"Add task" above for things like Docker-based backup jobs.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.map((group) => {
|
||||||
|
const byType = new Map<string, TaskRecord[]>();
|
||||||
|
for (const task of group.tasks) {
|
||||||
|
const list = byType.get(task.scheduleType) ?? [];
|
||||||
|
list.push(task);
|
||||||
|
byType.set(task.scheduleType, list);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="card mb-3" key={group.serverId}>
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">{group.serverName}</h3>
|
||||||
|
</div>
|
||||||
|
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
|
||||||
|
<div key={scheduleType}>
|
||||||
|
<div className="card-body py-2 bg-secondary-lt">
|
||||||
|
<strong>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Command</th>
|
||||||
|
<th>Schedule</th>
|
||||||
|
<th>Next run</th>
|
||||||
|
<th>Status</th>
|
||||||
|
{canEditTasks && <th className="w-1">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{groupTasks.map((task) => {
|
||||||
|
const nextRun = computeNextRun(task);
|
||||||
|
return (
|
||||||
|
<tr key={task.id}>
|
||||||
|
<td>{task.name}</td>
|
||||||
|
<td className="text-secondary">{task.command ?? "—"}</td>
|
||||||
|
<td>
|
||||||
|
<div>{describeSchedule(task)}</div>
|
||||||
|
{task.scheduleExpression && (
|
||||||
|
<span className="text-secondary small">{task.scheduleExpression}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{nextRun ? (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
nextRun.estimated
|
||||||
|
? "Estimated from the cron expression in your browser's timezone — the server may run in a different timezone"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{nextRun.estimated ? "~" : ""}
|
||||||
|
{formatDateTime(nextRun.date)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{task.isStale ? (
|
||||||
|
<span className="badge bg-red-lt text-red me-1">Missing</span>
|
||||||
|
) : task.enabled ? (
|
||||||
|
<span className="badge bg-green-lt text-green me-1">Enabled</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-secondary-lt text-secondary me-1">Disabled</span>
|
||||||
|
)}
|
||||||
|
{task.origin === "manual" && <span className="badge bg-blue-lt">Manual</span>}
|
||||||
|
</td>
|
||||||
|
{canEditTasks && (
|
||||||
|
<td>
|
||||||
|
{task.origin === "manual" && (
|
||||||
|
<div className="btn-list flex-nowrap">
|
||||||
|
<button className="btn btn-sm" onClick={() => openEditTask(task)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm btn-outline-danger" onClick={() => deleteTask(task)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Formats a date as "YYYY-MM-DD HH:mm:ss" on a 24-hour clock. */
|
||||||
|
export function formatDateTime(date: Date): string {
|
||||||
|
return date.toLocaleString("sv-SE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user