first commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "web-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--workspace", "web"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Public URL the app is reachable at (used for OIDC redirect_uri and cookie behavior).
|
||||||
|
APP_BASE_URL=https://schedule.example.lan
|
||||||
|
|
||||||
|
# Random long string used to sign session cookies. Generate with:
|
||||||
|
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
SESSION_SECRET=change-me-to-a-random-64-char-hex-string
|
||||||
|
|
||||||
|
# Port docker-compose publishes on the host (container always listens on 3000).
|
||||||
|
HOST_PORT=3000
|
||||||
|
|
||||||
|
# --- Authentik OIDC application/provider ---
|
||||||
|
# Create an OAuth2/OIDC "Provider" in Authentik with:
|
||||||
|
# Redirect URI: <APP_BASE_URL>/auth/callback
|
||||||
|
# Scopes: openid, email, profile
|
||||||
|
# then create an "Application" using that provider, and assign the users/groups
|
||||||
|
# who should be able to sign in. Copy the provider's values below.
|
||||||
|
AUTHENTIK_ISSUER_URL=https://authentik.example.lan/application/o/schedule-task-manager/
|
||||||
|
AUTHENTIK_CLIENT_ID=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
data/*.sqlite
|
||||||
|
data/*.sqlite-*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY server/package.json server/package.json
|
||||||
|
COPY web/package.json web/package.json
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY server/package.json server/package.json
|
||||||
|
RUN npm ci --omit=dev --workspace server
|
||||||
|
|
||||||
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
|
COPY --from=build /app/web/dist ./web/dist
|
||||||
|
COPY server/drizzle ./server/drizzle
|
||||||
|
COPY agent ./agent
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||||
|
CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "server/dist/index.js"]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Schedule Task Manager
|
||||||
|
|
||||||
|
Tracks scheduled tasks (cron jobs, systemd timers) across your homelab servers,
|
||||||
|
grouped by server and schedule type. Servers push their task list to this app
|
||||||
|
via a small agent script; sign-in is delegated to Authentik (OIDC).
|
||||||
|
|
||||||
|
v1 scope: Linux servers only (cron + systemd timers). Windows Task Scheduler
|
||||||
|
support is a documented future addition — see [`agent/windows/README.md`](agent/windows/README.md).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `server/` — Express + TypeScript API, SQLite (via libSQL) storage, Authentik OIDC login.
|
||||||
|
- `web/` — React + Vite single-page app, served as static files by the Express server.
|
||||||
|
- `agent/linux/` — `report-tasks.sh` (collects cron + systemd timer data) and
|
||||||
|
`install.sh` (installs it as a systemd timer on a target server).
|
||||||
|
|
||||||
|
Data model, API contract, and sync behavior are documented in code:
|
||||||
|
[`server/src/db/schema.ts`](server/src/db/schema.ts),
|
||||||
|
[`server/src/routes/agentReport.ts`](server/src/routes/agentReport.ts),
|
||||||
|
[`server/src/services/taskSync.ts`](server/src/services/taskSync.ts).
|
||||||
|
|
||||||
|
## 1. Set up an Authentik application
|
||||||
|
|
||||||
|
In Authentik:
|
||||||
|
|
||||||
|
1. Create an **OAuth2/OpenID Provider**:
|
||||||
|
- Redirect URI: `<APP_BASE_URL>/auth/callback` (e.g. `https://schedule.example.lan/auth/callback`)
|
||||||
|
- Scopes: `openid`, `email`, `profile`
|
||||||
|
2. Create an **Application** using that provider, and assign the users/groups who
|
||||||
|
should be allowed to sign in — Authentik controls who can authenticate; the
|
||||||
|
app itself has no separate user list.
|
||||||
|
3. Copy the provider's issuer URL, client ID, and client secret into `.env`
|
||||||
|
(see `.env.example`).
|
||||||
|
|
||||||
|
## 2. Run with Docker (recommended)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env: APP_BASE_URL, SESSION_SECRET, AUTHENTIK_*
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The app listens on `HOST_PORT` (default `3000`). SQLite data and session
|
||||||
|
files persist in `./data` on the host.
|
||||||
|
|
||||||
|
## 3. Local development
|
||||||
|
|
||||||
|
Requires Node.js 20+.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # fill in AUTHENTIK_* to test real login, or leave
|
||||||
|
# blank to run everything except /auth/login locally
|
||||||
|
|
||||||
|
npm run dev:server # http://localhost:3000 (API)
|
||||||
|
npm run dev:web # http://localhost:5173 (Vite dev server, proxies /api and /auth to :3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:5173` during development. Database migrations run
|
||||||
|
automatically on server start.
|
||||||
|
|
||||||
|
## 4. Add a server and install the agent
|
||||||
|
|
||||||
|
1. Sign in, go to **Servers**, and add a server. The generated API token is
|
||||||
|
shown once — copy it.
|
||||||
|
2. On the target Linux server (as root):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://schedule.example.lan/agent/linux/install.sh | \
|
||||||
|
API_URL=https://schedule.example.lan API_TOKEN=stm_xxx bash
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs `report-tasks.sh` to `/usr/local/bin`, writes the API
|
||||||
|
credentials to `/etc/schedule-task-manager-agent.env`, and installs a
|
||||||
|
`schedule-task-manager-agent.timer` systemd timer that reports every 15
|
||||||
|
minutes (override with `INTERVAL_MINUTES=5` before the `bash` above).
|
||||||
|
|
||||||
|
Requires `curl`, `jq`, and `systemd` on the target server.
|
||||||
|
|
||||||
|
3. Tasks appear on the Dashboard, grouped by server and then by schedule type,
|
||||||
|
within one reporting interval.
|
||||||
|
|
||||||
|
Tasks that stop appearing in a server's report are marked **stale** rather
|
||||||
|
than deleted (so a temporary agent failure doesn't wipe history) and are
|
||||||
|
hidden by default — toggle "Show stale/missing tasks" to see them.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
See [`.env.example`](.env.example) for the full list with descriptions.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Installs the Schedule Task Manager agent as a systemd timer.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# curl -fsSL https://schedule.example.lan/agent/linux/install.sh | \
|
||||||
|
# API_URL=https://schedule.example.lan API_TOKEN=stm_xxx bash
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${API_URL:?Set API_URL to your Schedule Task Manager URL, e.g. https://schedule.example.lan}"
|
||||||
|
: "${API_TOKEN:?Set API_TOKEN to the per-server token generated on the Servers 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
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for bin in curl jq systemctl; do
|
||||||
|
if ! command -v "$bin" >/dev/null 2>&1; then
|
||||||
|
echo "Required dependency '$bin' is not installed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Installing Schedule Task Manager agent from $API_URL ..."
|
||||||
|
|
||||||
|
curl -fsSL "$API_URL/agent/linux/report-tasks.sh" -o "$INSTALL_DIR/schedule-task-manager-agent.sh"
|
||||||
|
chmod 755 "$INSTALL_DIR/schedule-task-manager-agent.sh"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
cat > "$CONFIG_DIR/schedule-task-manager-agent.env" <<EOF
|
||||||
|
API_URL=$API_URL
|
||||||
|
API_TOKEN=$API_TOKEN
|
||||||
|
EOF
|
||||||
|
chmod 600 "$CONFIG_DIR/schedule-task-manager-agent.env"
|
||||||
|
|
||||||
|
cat > "$SYSTEMD_DIR/schedule-task-manager-agent.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Report scheduled tasks to Schedule Task Manager
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
EnvironmentFile=$CONFIG_DIR/schedule-task-manager-agent.env
|
||||||
|
ExecStart=$INSTALL_DIR/schedule-task-manager-agent.sh
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$SYSTEMD_DIR/schedule-task-manager-agent.timer" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Periodically report scheduled tasks to Schedule Task Manager
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=${INTERVAL_MINUTES}min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now schedule-task-manager-agent.timer
|
||||||
|
|
||||||
|
echo "Installed. Running an initial report now..."
|
||||||
|
"$INSTALL_DIR/schedule-task-manager-agent.sh"
|
||||||
|
|
||||||
|
echo "Done. The agent reports every ${INTERVAL_MINUTES} minute(s) via the 'schedule-task-manager-agent.timer' systemd timer."
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Collects cron jobs and systemd timers on this host and POSTs them to the
|
||||||
|
# Schedule Task 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://schedule.example.lan API_TOKEN=stm_xxx ./report-tasks.sh --dry-run
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV_FILE="${ENV_FILE:-/etc/schedule-task-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
|
||||||
|
|
||||||
|
for bin in curl jq; do
|
||||||
|
if ! command -v "$bin" >/dev/null 2>&1; then
|
||||||
|
echo "Required dependency '$bin' is not installed." >&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/stm-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/stm-agent-response.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Reported $(jq 'length' <<<"$TASKS_JSON") task(s) successfully."
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Windows agent (planned, not yet implemented)
|
||||||
|
|
||||||
|
v1 of Schedule Task Manager only supports Linux servers (cron + systemd timers). A
|
||||||
|
Windows agent is a natural v2 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.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
schedule-task-manager:
|
||||||
|
build: .
|
||||||
|
container_name: schedule-task-manager
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-3000}:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
PORT: 3000
|
||||||
|
NODE_ENV: production
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
Generated
+5248
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "schedule-task-manager",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev:server": "npm run dev --workspace server",
|
||||||
|
"dev:web": "npm run dev --workspace web",
|
||||||
|
"build": "npm run build --workspace web && npm run build --workspace server",
|
||||||
|
"db:generate": "npm run db:generate --workspace server",
|
||||||
|
"db:migrate": "npm run db:migrate --workspace server",
|
||||||
|
"start": "npm run start --workspace server"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "sqlite",
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dbCredentials: {
|
||||||
|
url: `file:${process.env.DATABASE_PATH ?? "../data/schedule-task-manager.sqlite"}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE `scheduled_tasks` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`server_id` integer NOT NULL,
|
||||||
|
`schedule_type` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`command` text,
|
||||||
|
`schedule_expression` text,
|
||||||
|
`source` text,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`next_run_at` text,
|
||||||
|
`raw_metadata` text,
|
||||||
|
`is_stale` integer DEFAULT false NOT NULL,
|
||||||
|
`first_seen_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`last_seen_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `servers` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`hostname` text,
|
||||||
|
`os_type` text DEFAULT 'linux' NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`api_token_hash` text NOT NULL,
|
||||||
|
`api_token_prefix` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`last_seen_at` text
|
||||||
|
);
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "81d34268-977d-459b-b4a1-8d62b2d81a56",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"scheduled_tasks": {
|
||||||
|
"name": "scheduled_tasks",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"server_id": {
|
||||||
|
"name": "server_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_type": {
|
||||||
|
"name": "schedule_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"name": "command",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_expression": {
|
||||||
|
"name": "schedule_expression",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"next_run_at": {
|
||||||
|
"name": "next_run_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"raw_metadata": {
|
||||||
|
"name": "raw_metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_stale": {
|
||||||
|
"name": "is_stale",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"first_seen_at": {
|
||||||
|
"name": "first_seen_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
},
|
||||||
|
"last_seen_at": {
|
||||||
|
"name": "last_seen_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"scheduled_tasks_server_id_servers_id_fk": {
|
||||||
|
"name": "scheduled_tasks_server_id_servers_id_fk",
|
||||||
|
"tableFrom": "scheduled_tasks",
|
||||||
|
"tableTo": "servers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"server_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"servers": {
|
||||||
|
"name": "servers",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"hostname": {
|
||||||
|
"name": "hostname",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"os_type": {
|
||||||
|
"name": "os_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'linux'"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"api_token_hash": {
|
||||||
|
"name": "api_token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"api_token_prefix": {
|
||||||
|
"name": "api_token_prefix",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
},
|
||||||
|
"last_seen_at": {
|
||||||
|
"name": "last_seen_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1783628241015,
|
||||||
|
"tag": "0000_aspiring_blink",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "tsx src/db/migrate.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@libsql/client": "^0.14.0",
|
||||||
|
"drizzle-orm": "^0.38.4",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"openid-client": "^6.1.7",
|
||||||
|
"session-file-store": "^1.5.0",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/express-session": "^1.18.1",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/session-file-store": "^1.2.5",
|
||||||
|
"drizzle-kit": "^0.30.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (req.session.user) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(401).json({ error: "unauthorized" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as client from "openid-client";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
|
||||||
|
let configPromise: Promise<client.Configuration> | null = null;
|
||||||
|
|
||||||
|
export function getOidcConfig(): Promise<client.Configuration> {
|
||||||
|
if (!configPromise) {
|
||||||
|
configPromise = client.discovery(
|
||||||
|
new URL(env.authentik.issuerUrl),
|
||||||
|
env.authentik.clientId,
|
||||||
|
env.authentik.clientSecret,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return configPromise;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import * as client from "openid-client";
|
||||||
|
import { getOidcConfig } from "./oidc.js";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
authRouter.get("/login", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const config = await getOidcConfig();
|
||||||
|
const codeVerifier = client.randomPKCECodeVerifier();
|
||||||
|
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
const state = client.randomState();
|
||||||
|
|
||||||
|
req.session.pendingAuth = { codeVerifier, state };
|
||||||
|
|
||||||
|
const redirectUri = new URL("/auth/callback", env.appBaseUrl).toString();
|
||||||
|
const authUrl = client.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: "openid email profile",
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.session.save((err) => {
|
||||||
|
if (err) return next(err);
|
||||||
|
res.redirect(authUrl.href);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get("/callback", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const pending = req.session.pendingAuth;
|
||||||
|
if (!pending) {
|
||||||
|
return res.status(400).send("Login session expired. Please try signing in again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getOidcConfig();
|
||||||
|
const currentUrl = new URL(req.originalUrl, env.appBaseUrl);
|
||||||
|
|
||||||
|
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||||
|
pkceCodeVerifier: pending.codeVerifier,
|
||||||
|
expectedState: pending.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
const claims = tokens.claims();
|
||||||
|
if (!claims?.sub) {
|
||||||
|
return res.status(400).send("Identity provider did not return a valid identity.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let email: string | undefined = typeof claims.email === "string" ? claims.email : undefined;
|
||||||
|
let name: string | undefined = typeof claims.name === "string" ? claims.name : undefined;
|
||||||
|
try {
|
||||||
|
const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub);
|
||||||
|
email = userinfo.email ?? email;
|
||||||
|
name = userinfo.name ?? userinfo.preferred_username ?? name;
|
||||||
|
} catch {
|
||||||
|
// fall back to ID token claims already captured above
|
||||||
|
}
|
||||||
|
|
||||||
|
delete req.session.pendingAuth;
|
||||||
|
req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token };
|
||||||
|
req.session.save((err) => {
|
||||||
|
if (err) return next(err);
|
||||||
|
res.redirect("/");
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get("/logout", async (req, res, next) => {
|
||||||
|
const idToken = req.session.user?.idToken;
|
||||||
|
try {
|
||||||
|
const config = await getOidcConfig();
|
||||||
|
let endSessionUrl: URL | undefined;
|
||||||
|
try {
|
||||||
|
endSessionUrl = client.buildEndSessionUrl(config, {
|
||||||
|
post_logout_redirect_uri: env.appBaseUrl,
|
||||||
|
...(idToken ? { id_token_hint: idToken } : {}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Provider doesn't advertise RP-Initiated Logout; just clear our own session.
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.destroy((err) => {
|
||||||
|
if (err) return next(err);
|
||||||
|
res.redirect(endSessionUrl ? endSessionUrl.href : "/");
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createClient } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { resolveDataPath } from "../paths.js";
|
||||||
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
|
const dbPath = resolveDataPath(process.env.DATABASE_PATH ?? "../data/schedule-task-manager.sqlite");
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
|
||||||
|
const client = createClient({ url: `file:${dbPath}` });
|
||||||
|
await client.execute("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
|
export { client };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { db, client } from "./client.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export async function runMigrations() {
|
||||||
|
await migrate(db, { migrationsFolder: join(__dirname, "..", "..", "drizzle") });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow running directly via `npm run db:migrate`
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||||
|
await runMigrations();
|
||||||
|
client.close();
|
||||||
|
console.log("Migrations applied.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const servers = sqliteTable("servers", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
hostname: text("hostname"),
|
||||||
|
osType: text("os_type").notNull().default("linux"),
|
||||||
|
description: text("description"),
|
||||||
|
apiTokenHash: text("api_token_hash").notNull(),
|
||||||
|
apiTokenPrefix: text("api_token_prefix").notNull(),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
lastSeenAt: text("last_seen_at"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
serverId: integer("server_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => servers.id, { onDelete: "cascade" }),
|
||||||
|
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer'
|
||||||
|
name: text("name").notNull(),
|
||||||
|
command: text("command"),
|
||||||
|
scheduleExpression: text("schedule_expression"),
|
||||||
|
source: text("source"),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
nextRunAt: text("next_run_at"),
|
||||||
|
rawMetadata: text("raw_metadata"),
|
||||||
|
isStale: integer("is_stale", { mode: "boolean" }).notNull().default(false),
|
||||||
|
firstSeenAt: text("first_seen_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
lastSeenAt: text("last_seen_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const env = {
|
||||||
|
port: Number(process.env.PORT ?? 3000),
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||||
|
appBaseUrl: process.env.APP_BASE_URL ?? "http://localhost:3000",
|
||||||
|
sessionSecret: process.env.SESSION_SECRET ?? "dev-insecure-session-secret-change-me",
|
||||||
|
sessionDir: process.env.SESSION_DIR ?? "../data/sessions",
|
||||||
|
databasePath: process.env.DATABASE_PATH ?? "../data/schedule-task-manager.sqlite",
|
||||||
|
authentik: {
|
||||||
|
issuerUrl: process.env.AUTHENTIK_ISSUER_URL ?? "",
|
||||||
|
clientId: process.env.AUTHENTIK_CLIENT_ID ?? "",
|
||||||
|
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "",
|
||||||
|
},
|
||||||
|
get authEnabled() {
|
||||||
|
return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function warnIfAuthNotConfigured() {
|
||||||
|
if (!env.authEnabled) {
|
||||||
|
console.warn(
|
||||||
|
"AUTHENTIK_ISSUER_URL / AUTHENTIK_CLIENT_ID / AUTHENTIK_CLIENT_SECRET are not fully set. " +
|
||||||
|
"The app will boot, but /auth/login and /auth/logout will fail until they are configured.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import express from "express";
|
||||||
|
import session from "express-session";
|
||||||
|
import FileStoreFactory from "session-file-store";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { mkdirSync, existsSync } from "node:fs";
|
||||||
|
import { env, warnIfAuthNotConfigured } from "./env.js";
|
||||||
|
import { resolveDataPath } from "./paths.js";
|
||||||
|
import { runMigrations } from "./db/migrate.js";
|
||||||
|
import { authRouter } from "./auth/router.js";
|
||||||
|
import { meRouter } from "./routes/me.js";
|
||||||
|
import { serversRouter } from "./routes/servers.js";
|
||||||
|
import { tasksRouter } from "./routes/tasks.js";
|
||||||
|
import { agentReportRouter } from "./routes/agentReport.js";
|
||||||
|
|
||||||
|
warnIfAuthNotConfigured();
|
||||||
|
await runMigrations();
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webDist = join(__dirname, "..", "..", "web", "dist");
|
||||||
|
const agentDir = join(__dirname, "..", "..", "agent");
|
||||||
|
|
||||||
|
const FileStore = FileStoreFactory(session);
|
||||||
|
const sessionDir = resolveDataPath(env.sessionDir);
|
||||||
|
mkdirSync(sessionDir, { recursive: true });
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
store: new FileStore({ path: sessionDir, logFn: () => {} }),
|
||||||
|
secret: env.sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: env.nodeEnv === "production",
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
// Publicly readable so `curl .../agent/linux/install.sh | bash` works from a
|
||||||
|
// freshly provisioned server with no prior session. Contains no secrets.
|
||||||
|
if (existsSync(agentDir)) {
|
||||||
|
app.use("/agent", express.static(agentDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use("/auth", authRouter);
|
||||||
|
app.use("/api/me", meRouter);
|
||||||
|
app.use("/api/servers", serversRouter);
|
||||||
|
app.use("/api/tasks", tasksRouter);
|
||||||
|
app.use("/api/agent/report", agentReportRouter);
|
||||||
|
|
||||||
|
if (existsSync(webDist)) {
|
||||||
|
app.use(express.static(webDist));
|
||||||
|
app.get("*", (_req, res) => res.sendFile(join(webDist, "index.html")));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: "internal_error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(env.port, () => {
|
||||||
|
console.log(`schedule-task-manager listening on port ${env.port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve, isAbsolute } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const serverRoot = resolve(__dirname, ".."); // server/
|
||||||
|
|
||||||
|
/** Resolves a config path relative to the server package root, regardless of process.cwd(). */
|
||||||
|
export function resolveDataPath(path: string): string {
|
||||||
|
return isAbsolute(path) ? path : resolve(serverRoot, path);
|
||||||
|
}
|
||||||
@@ -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,9 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { requireAuth } from "../auth/middleware.js";
|
||||||
|
|
||||||
|
export const meRouter = Router();
|
||||||
|
|
||||||
|
meRouter.get("/", requireAuth, (req, res) => {
|
||||||
|
const { sub, email, name } = req.session.user!;
|
||||||
|
res.json({ user: { sub, email, name } });
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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 } from "../auth/middleware.js";
|
||||||
|
import { generateApiToken } from "../services/tokens.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("/", 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();
|
||||||
|
|
||||||
|
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", 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" });
|
||||||
|
|
||||||
|
const { apiTokenHash, ...serverOut } = updated;
|
||||||
|
res.json({ server: serverOut, token });
|
||||||
|
});
|
||||||
|
|
||||||
|
serversRouter.delete("/:id", 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" });
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { and, eq, like, or } from "drizzle-orm";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { scheduledTasks, servers } from "../db/schema.js";
|
||||||
|
import { requireAuth } from "../auth/middleware.js";
|
||||||
|
|
||||||
|
export const tasksRouter = Router();
|
||||||
|
tasksRouter.use(requireAuth);
|
||||||
|
|
||||||
|
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 === "cron" || scheduleType === "systemd_timer") {
|
||||||
|
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({
|
||||||
|
id: scheduledTasks.id,
|
||||||
|
serverId: scheduledTasks.serverId,
|
||||||
|
serverName: servers.name,
|
||||||
|
scheduleType: scheduledTasks.scheduleType,
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
.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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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: eq(scheduledTasks.serverId, serverId),
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 belonging to this server that wasn't in this report is now stale,
|
||||||
|
// rather than deleted, so a bad/partial agent run doesn't wipe history.
|
||||||
|
if (seenIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(scheduledTasks)
|
||||||
|
.set({ isStale: true })
|
||||||
|
.where(and(eq(scheduledTasks.serverId, serverId), notInArray(scheduledTasks.id, seenIds)));
|
||||||
|
} else {
|
||||||
|
await db.update(scheduledTasks).set({ isStale: true }).where(eq(scheduledTasks.serverId, serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `stm_${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);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import "express-session";
|
||||||
|
|
||||||
|
declare module "express-session" {
|
||||||
|
interface SessionData {
|
||||||
|
user?: {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
idToken?: string;
|
||||||
|
};
|
||||||
|
pendingAuth?: {
|
||||||
|
codeVerifier: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Schedule Task Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cronstrue": "^2.53.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Routes, Route, NavLink } from "react-router-dom";
|
||||||
|
import { api, type CurrentUser, UnauthorizedError } from "./api/client";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Dashboard from "./pages/Dashboard";
|
||||||
|
import ServersAdmin from "./pages/ServersAdmin";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [user, setUser] = useState<CurrentUser | null | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.me().then(
|
||||||
|
(res) => setUser(res.user),
|
||||||
|
(err) => {
|
||||||
|
if (!(err instanceof UnauthorizedError)) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setUser(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (user === undefined) {
|
||||||
|
return <div className="loading-screen">Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="brand">Schedule Task Manager</div>
|
||||||
|
<nav>
|
||||||
|
<NavLink to="/" end>
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/servers">Servers</NavLink>
|
||||||
|
</nav>
|
||||||
|
<div className="user-info">
|
||||||
|
<span>{user.name ?? user.email ?? user.sub}</span>
|
||||||
|
<a href="/auth/logout">Sign out</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/servers" element={<ServersAdmin />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
export interface ServerRecord {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
hostname: string | null;
|
||||||
|
osType: string;
|
||||||
|
description: string | null;
|
||||||
|
apiTokenPrefix: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskRecord {
|
||||||
|
id: number;
|
||||||
|
serverId: number;
|
||||||
|
serverName: string;
|
||||||
|
scheduleType: "cron" | "systemd_timer";
|
||||||
|
name: string;
|
||||||
|
command: string | null;
|
||||||
|
scheduleExpression: string | null;
|
||||||
|
source: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
nextRunAt: string | null;
|
||||||
|
isStale: boolean;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends Error {}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new UnauthorizedError("unauthorized");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Request failed (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: () => request<{ user: CurrentUser }>("/api/me"),
|
||||||
|
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}` : ""}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ServerRecord } from "../api/client";
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
serverId: number | undefined;
|
||||||
|
scheduleType: string | undefined;
|
||||||
|
search: string;
|
||||||
|
includeStale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
servers: ServerRecord[];
|
||||||
|
filters: Filters;
|
||||||
|
onChange: (filters: Filters) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterBar({ servers, filters, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="filter-bar">
|
||||||
|
<select
|
||||||
|
value={filters.serverId ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...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>
|
||||||
|
<select
|
||||||
|
value={filters.scheduleType ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...filters, scheduleType: e.target.value || undefined })}
|
||||||
|
>
|
||||||
|
<option value="">All schedule types</option>
|
||||||
|
<option value="cron">Cron</option>
|
||||||
|
<option value="systemd_timer">systemd timer</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search name or command…"
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
||||||
|
/>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.includeStale}
|
||||||
|
onChange={(e) => onChange({ ...filters, includeStale: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Show stale/missing tasks
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { TaskRecord } from "../api/client";
|
||||||
|
import TaskTable from "./TaskTable";
|
||||||
|
|
||||||
|
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
cron: "Cron jobs",
|
||||||
|
systemd_timer: "systemd timers",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServerGroup({ serverName, tasks }: { serverName: string; tasks: TaskRecord[] }) {
|
||||||
|
const byType = new Map<string, TaskRecord[]>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
const list = byType.get(task.scheduleType) ?? [];
|
||||||
|
list.push(task);
|
||||||
|
byType.set(task.scheduleType, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="server-group">
|
||||||
|
<h2>{serverName}</h2>
|
||||||
|
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
|
||||||
|
<div key={scheduleType} className="schedule-type-group">
|
||||||
|
<h3>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</h3>
|
||||||
|
<TaskTable tasks={groupTasks} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import cronstrue from "cronstrue";
|
||||||
|
import type { TaskRecord } from "../api/client";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) {
|
||||||
|
return (
|
||||||
|
<table className="task-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Command</th>
|
||||||
|
<th>Schedule</th>
|
||||||
|
<th>Next run</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<tr key={task.id} className={task.isStale ? "stale" : undefined}>
|
||||||
|
<td>{task.name}</td>
|
||||||
|
<td className="mono">{task.command ?? "—"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="schedule-human">{describeSchedule(task)}</div>
|
||||||
|
{task.scheduleExpression && <span className="mono schedule-raw">{task.scheduleExpression}</span>}
|
||||||
|
</td>
|
||||||
|
<td>{task.nextRunAt ? new Date(task.nextRunAt).toLocaleString() : "—"}</td>
|
||||||
|
<td>
|
||||||
|
{task.isStale ? (
|
||||||
|
<span className="badge badge-stale">Missing</span>
|
||||||
|
) : task.enabled ? (
|
||||||
|
<span className="badge badge-ok">Enabled</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-disabled">Disabled</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { api, type ServerRecord, type TaskRecord } from "../api/client";
|
||||||
|
import FilterBar, { type Filters } from "../components/FilterBar";
|
||||||
|
import ServerGroup from "../components/ServerGroup";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [servers, setServers] = useState<ServerRecord[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<TaskRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filters, setFilters] = useState<Filters>({
|
||||||
|
serverId: undefined,
|
||||||
|
scheduleType: undefined,
|
||||||
|
search: "",
|
||||||
|
includeStale: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.servers
|
||||||
|
.list()
|
||||||
|
.then((res) => setServers(res.servers))
|
||||||
|
.catch((err) => setError(String(err)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
api.tasks
|
||||||
|
.list(filters)
|
||||||
|
.then((res) => {
|
||||||
|
setTasks(res.tasks);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(String(err)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<FilterBar servers={servers} filters={filters} onChange={setFilters} />
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{loading && <div className="loading-inline">Loading tasks…</div>}
|
||||||
|
{!loading && groups.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
No scheduled tasks found yet. Add a server under "Servers" and install the agent to start reporting.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<ServerGroup key={group.serverId} serverName={group.serverName} tasks={group.tasks} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export default function Login() {
|
||||||
|
return (
|
||||||
|
<div className="login-screen">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>Schedule Task Manager</h1>
|
||||||
|
<p>Sign in with your homelab identity provider to continue.</p>
|
||||||
|
<a className="btn btn-primary" href="/auth/login">
|
||||||
|
Sign in with Authentik
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { api, type ServerRecord } from "../api/client";
|
||||||
|
|
||||||
|
export default function ServersAdmin() {
|
||||||
|
const [servers, setServers] = useState<ServerRecord[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [hostname, setHostname] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = () => api.servers.list().then((res) => setServers(res.servers));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch((err) => setError(String(err)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCreate(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.servers.create({
|
||||||
|
name,
|
||||||
|
hostname: hostname || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
});
|
||||||
|
setNewToken(result);
|
||||||
|
setName("");
|
||||||
|
setHostname("");
|
||||||
|
setDescription("");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRotate(id: number) {
|
||||||
|
try {
|
||||||
|
const result = await api.servers.rotateToken(id);
|
||||||
|
setNewToken(result);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("Remove this server and all its tracked tasks?")) return;
|
||||||
|
try {
|
||||||
|
await api.servers.remove(id);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="servers-admin">
|
||||||
|
<section className="add-server">
|
||||||
|
<h2>Add a server</h2>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
placeholder="Server name (e.g. nas01)"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Hostname (optional)"
|
||||||
|
value={hostname}
|
||||||
|
onChange={(e) => setHostname(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Add server
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
{newToken && (
|
||||||
|
<section className="token-reveal">
|
||||||
|
<h3>Agent token for {newToken.server.name}</h3>
|
||||||
|
<p>This token is only shown once — copy it now.</p>
|
||||||
|
<code className="token-value">{newToken.token}</code>
|
||||||
|
<p>Install the agent on the server:</p>
|
||||||
|
<pre className="install-command">{`curl -fsSL ${window.location.origin}/agent/linux/install.sh | API_URL=${window.location.origin} API_TOKEN=${newToken.token} bash`}</pre>
|
||||||
|
<button onClick={() => setNewToken(null)} className="btn">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="server-list">
|
||||||
|
<h2>Servers</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Hostname</th>
|
||||||
|
<th>Token</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>{s.name}</td>
|
||||||
|
<td>{s.hostname ?? "—"}</td>
|
||||||
|
<td className="mono">{s.apiTokenPrefix}…</td>
|
||||||
|
<td>{s.lastSeenAt ? new Date(s.lastSeenAt).toLocaleString() : "never"}</td>
|
||||||
|
<td className="actions">
|
||||||
|
<button onClick={() => handleRotate(s.id)} className="btn btn-small">
|
||||||
|
Rotate token
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(s.id)} className="btn btn-small btn-danger">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #f5f6f8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #dde1e6;
|
||||||
|
--text: #1c2126;
|
||||||
|
--text-muted: #5b6470;
|
||||||
|
--accent: #2f6fed;
|
||||||
|
--accent-contrast: #ffffff;
|
||||||
|
--danger: #d64545;
|
||||||
|
--stale-bg: #fff6e5;
|
||||||
|
--ok-bg: #e5f6ea;
|
||||||
|
--ok-text: #1f8a3d;
|
||||||
|
--disabled-bg: #eceff2;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #14171c;
|
||||||
|
--surface: #1c2128;
|
||||||
|
--border: #2c333c;
|
||||||
|
--text: #e6e9ed;
|
||||||
|
--text-muted: #9aa4b1;
|
||||||
|
--accent: #5b8dfc;
|
||||||
|
--accent-contrast: #0b1220;
|
||||||
|
--stale-bg: #332a12;
|
||||||
|
--ok-bg: #123321;
|
||||||
|
--ok-text: #4fd07a;
|
||||||
|
--disabled-bg: #232830;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-screen,
|
||||||
|
.login-screen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
max-width: 380px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .brand {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header nav a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header nav a.active,
|
||||||
|
.app-header nav a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar select,
|
||||||
|
.filter-bar input[type="search"] {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-group {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-group h2 {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-type-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-type-group h3 {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.stale {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-human {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-raw {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ok {
|
||||||
|
background: var(--ok-bg);
|
||||||
|
color: var(--ok-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-disabled {
|
||||||
|
background: var(--disabled-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-stale {
|
||||||
|
background: var(--stale-bg);
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state,
|
||||||
|
.loading-inline {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #fdecec;
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid #f3b8b8;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-server form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-server input {
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-reveal {
|
||||||
|
background: var(--ok-bg);
|
||||||
|
border: 1px solid var(--ok-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-value {
|
||||||
|
display: block;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-command {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.servers-admin section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/filterbar.tsx","./src/components/servergroup.tsx","./src/components/tasktable.tsx","./src/pages/dashboard.tsx","./src/pages/login.tsx","./src/pages/serversadmin.tsx"],"version":"5.9.3"}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000",
|
||||||
|
"/auth": "http://localhost:3000",
|
||||||
|
"/health": "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user