Add server hardware/network detail view with Proxmox sync and agent reporting

Servers & Tasks only tracked scheduled tasks — there was no overview of
the servers themselves and no way to see CPU/RAM/disk/IP info. Add a
clickable server overview (visible to every role, not just admins) that
opens a per-server detail page.

- servers table gains an agent-reported hardware/network snapshot
  (IPs, CPU model/cores/load, memory, disks) and an optional link to a
  Proxmox VM/LXC (integration + node + guest type + vmid).
- Proxmox adapter gains getGuestDetail(): live cores/memory/disk from
  /config, live cpu/mem/uptime from /status/current, and IPs (LXC net
  config directly, QEMU via a best-effort guest-agent call that degrades
  gracefully when the agent isn't installed).
- New GET /api/servers/:id/detail combines whichever hardware source
  applies (live Proxmox vs. last agent report) with a DNS reverse-lookup
  against the DNS module's own record cache, so matching hostnames show
  up next to each IP. New PATCH /api/servers/:id manages the Proxmox
  link.
- agent/linux/report-tasks.sh now also collects and reports IPs,
  CPU/memory/disk info on every check-in (load-average-based CPU number,
  not instantaneous, to keep the agent a cheap oneshot).
- Extracted the per-server task table into a shared ServerTaskTable
  component so the all-servers view and the new detail page render
  tasks identically.

Verified server-side end-to-end against the real dev server (agent
report -> detail endpoint -> DNS match) and the Proxmox adapter against
a mock HTTPS server covering LXC/QEMU config parsing and the
guest-agent-unavailable fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 13:02:50 +02:00
1 parent 1f0e6a9d4c
commit b9409d3095
15 files changed
+2206 -140

No files matched your search

+5 -1
View File
@@ -24,7 +24,11 @@ All modules from the original plan are built:
vars) and their credentials are encrypted at rest vars) and their credentials are encrypted at rest
- **Servers & Tasks** — cron/systemd tracking across Debian/Raspbian servers - **Servers & Tasks** — cron/systemd tracking across Debian/Raspbian servers
via a lightweight push agent (`agent/linux/`), plus manual entries for via a lightweight push agent (`agent/linux/`), plus manual entries for
things an agent can't see (Docker jobs, backups) things an agent can't see (Docker jobs, backups). A clickable server
overview opens a detail page with CPU/RAM/disk status, IP addresses, and
matching DNS names (looked up from the DNS module's cache) — live from
Proxmox for VM/LXC-backed servers, or from the agent's own hardware
report for everything else.
- **Integrations → Tailscale** — device list with online/authorized status, - **Integrations → Tailscale** — device list with online/authorized status,
and authorize/deauthorize/remove actions; a live device-count widget. and authorize/deauthorize/remove actions; a live device-count widget.
- **Integrations → Gitea** — repo list with each repo's last CI run status, - **Integrations → Gitea** — repo list with each repo's last CI run status,
+52 -1
View File
@@ -173,16 +173,67 @@ collect_systemd_timers() {
done < <(jq -c '.[]' <<<"$timers_json") done < <(jq -c '.[]' <<<"$timers_json")
} }
collect_system_info() {
local ip_json="[]"
if command -v ip >/dev/null 2>&1; then
ip_json=$(ip -4 -o addr show scope global 2>/dev/null \
| awk '{print $4}' | cut -d/ -f1 \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
fi
local cpu_model="" cpu_cores=0 cpu_load_percent="null"
cpu_model=$(grep -m1 "model name" /proc/cpuinfo 2>/dev/null | cut -d: -f2- | sed 's/^ *//')
cpu_cores=$(nproc 2>/dev/null || echo 0)
if [[ -r /proc/loadavg && "$cpu_cores" -gt 0 ]]; then
local load1
load1=$(awk '{print $1}' /proc/loadavg)
cpu_load_percent=$(awk -v l="$load1" -v c="$cpu_cores" 'BEGIN { printf "%.1f", (l/c)*100 }')
fi
local mem_total=0 mem_used=0
if command -v free >/dev/null 2>&1; then
read -r mem_total mem_used < <(free -b | awk '/^Mem:/ {print $2, $3}')
fi
local disks_json="[]"
if command -v df >/dev/null 2>&1; then
disks_json=$(df -B1 --output=target,size,used -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null \
| tail -n +2 \
| awk '{print $1"\t"$2"\t"$3}' \
| jq -R -s -c '
split("\n") | map(select(length > 0) | split("\t")) |
map({mount: .[0], size_bytes: (.[1]|tonumber), used_bytes: (.[2]|tonumber)})
')
fi
SYSTEM_JSON=$(jq -n \
--argjson ip_addresses "$ip_json" \
--arg cpu_model "$cpu_model" \
--argjson cpu_cores "${cpu_cores:-0}" \
--argjson cpu_load_percent "$cpu_load_percent" \
--argjson mem_total_bytes "${mem_total:-0}" \
--argjson mem_used_bytes "${mem_used:-0}" \
--argjson disks "$disks_json" \
'{
ip_addresses: $ip_addresses,
cpu: { model: $cpu_model, cores: $cpu_cores, load_percent: $cpu_load_percent },
memory: { total_bytes: $mem_total_bytes, used_bytes: $mem_used_bytes },
disks: $disks
}')
}
collect_cron collect_cron
collect_systemd_timers collect_systemd_timers
collect_system_info
HOSTNAME_VALUE=$(hostname -f 2>/dev/null || hostname) HOSTNAME_VALUE=$(hostname -f 2>/dev/null || hostname)
PAYLOAD=$(jq -n \ PAYLOAD=$(jq -n \
--arg hostname "$HOSTNAME_VALUE" \ --arg hostname "$HOSTNAME_VALUE" \
--arg os_type "linux" \ --arg os_type "linux" \
--arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--argjson system "$SYSTEM_JSON" \
--argjson tasks "$TASKS_JSON" \ --argjson tasks "$TASKS_JSON" \
'{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, tasks: $tasks}') '{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, system: $system, tasks: $tasks}')
if [[ "$DRY_RUN" == "1" ]]; then if [[ "$DRY_RUN" == "1" ]]; then
echo "$PAYLOAD" | jq . echo "$PAYLOAD" | jq .
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE `servers` ADD `ip_addresses` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `cpu_model` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `cpu_cores` integer;--> statement-breakpoint
ALTER TABLE `servers` ADD `cpu_load_percent` real;--> statement-breakpoint
ALTER TABLE `servers` ADD `mem_total_bytes` integer;--> statement-breakpoint
ALTER TABLE `servers` ADD `mem_used_bytes` integer;--> statement-breakpoint
ALTER TABLE `servers` ADD `disks` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `proxmox_integration_id` integer REFERENCES integrations(id);--> statement-breakpoint
ALTER TABLE `servers` ADD `proxmox_node` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `proxmox_guest_type` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `proxmox_vmid` integer;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1789419241921, "when": 1789419241921,
"tag": "0000_colossal_violations", "tag": "0000_colossal_violations",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1789468464784,
"tag": "0001_yummy_luke_cage",
"breakpoints": true
} }
] ]
} }
+21 -1
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core"; import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
// ─── Users & roles ────────────────────────────────────────────────────────── // ─── Users & roles ──────────────────────────────────────────────────────────
@@ -150,6 +150,9 @@ export const dnsRecordsCache = sqliteTable("dns_records_cache", {
// ─── Servers & scheduled tasks (ported from Schedule Task Manager) ───────── // ─── Servers & scheduled tasks (ported from Schedule Task Manager) ─────────
export const proxmoxGuestTypes = ["qemu", "lxc"] as const;
export type ProxmoxGuestTypeCol = (typeof proxmoxGuestTypes)[number];
export const servers = sqliteTable("servers", { export const servers = sqliteTable("servers", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),
@@ -162,6 +165,23 @@ export const servers = sqliteTable("servers", {
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
lastSeenAt: text("last_seen_at"), lastSeenAt: text("last_seen_at"),
// Agent-reported hardware/network snapshot — updated on every agent report.
ipAddresses: text("ip_addresses"), // JSON string[]
cpuModel: text("cpu_model"),
cpuCores: integer("cpu_cores"),
cpuLoadPercent: real("cpu_load_percent"),
memTotalBytes: integer("mem_total_bytes"),
memUsedBytes: integer("mem_used_bytes"),
disks: text("disks"), // JSON string: {mount, sizeBytes, usedBytes}[]
// Optional link to a Proxmox VM/LXC — set by an admin, not the agent.
proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, {
onDelete: "set null",
}),
proxmoxNode: text("proxmox_node"),
proxmoxGuestType: text("proxmox_guest_type").$type<ProxmoxGuestTypeCol>(),
proxmoxVmid: integer("proxmox_vmid"),
}); });
export const scheduledTasks = sqliteTable("scheduled_tasks", { export const scheduledTasks = sqliteTable("scheduled_tasks", {
+114 -1
View File
@@ -38,14 +38,54 @@ export interface ProxmoxGuest {
uptime: number | null; uptime: number | null;
} }
export interface ProxmoxGuestDetail {
vmid: number;
node: string;
type: ProxmoxGuestType;
name: string;
status: string;
cpuCores: number | null;
cpuUsagePercent: number | null;
memoryBytes: number | null;
memUsedBytes: number | null;
diskBytes: number | null;
uptime: number | null;
ipAddresses: string[];
/** QEMU only — false when the guest agent call failed (not installed/running). Always true for LXC (IPs read from config, no agent needed). */
guestAgentAvailable: boolean;
}
export interface ProxmoxAdapter { export interface ProxmoxAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listGuests(): Promise<ProxmoxGuest[]>; listGuests(): Promise<ProxmoxGuest[]>;
getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise<ProxmoxGuestDetail>;
startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>; startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>; stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>; restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
} }
function parseSizeToBytes(size: string): number | null {
const match = size.match(/^(\d+(?:\.\d+)?)\s*([KMGT])?$/i);
if (!match) return null;
const value = parseFloat(match[1]);
const unit = (match[2] ?? "").toUpperCase();
const multiplier = ({ "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 } as Record<string, number>)[unit] ?? 1;
return Math.round(value * multiplier);
}
function extractSizeParam(configValue: string): number | null {
const match = configValue.match(/(?:^|,)size=([\d.]+[KMGT]?)/i);
return match ? parseSizeToBytes(match[1]) : null;
}
function extractIpFromNetConfig(configValue: string): string | null {
const match = configValue.match(/(?:^|,)ip=([^,]+)/i);
if (!match) return null;
const ip = match[1];
if (ip.toLowerCase() === "dhcp" || ip.toLowerCase() === "manual") return null;
return ip.split("/")[0];
}
interface RawResponse { interface RawResponse {
status: number; status: number;
text: () => string; text: () => string;
@@ -147,6 +187,79 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
return perNode.flat(); return perNode.flat();
} }
async function getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise<ProxmoxGuestDetail> {
const [config, status] = await Promise.all([
api("GET", `/nodes/${node}/${type}/${vmid}/config`),
api("GET", `/nodes/${node}/${type}/${vmid}/status/current`),
]);
let cpuCores: number | null = null;
let diskBytes: number | null = null;
const ipAddresses: string[] = [];
let guestAgentAvailable = true;
if (type === "lxc") {
cpuCores = typeof config.cores === "number" ? config.cores : null;
if (typeof config.rootfs === "string") {
diskBytes = extractSizeParam(config.rootfs);
}
for (const key of Object.keys(config)) {
if (/^net\d+$/.test(key) && typeof config[key] === "string") {
const ip = extractIpFromNetConfig(config[key]);
if (ip) ipAddresses.push(ip);
}
}
} else {
const sockets = typeof config.sockets === "number" ? config.sockets : 1;
cpuCores = typeof config.cores === "number" ? config.cores * sockets : null;
let totalDisk = 0;
let foundDisk = false;
for (const key of Object.keys(config)) {
if (/^(scsi|virtio|sata|ide)\d+$/.test(key) && typeof config[key] === "string") {
const size = extractSizeParam(config[key]);
if (size !== null) {
totalDisk += size;
foundDisk = true;
}
}
}
diskBytes = foundDisk ? totalDisk : null;
try {
const agentData = await api("GET", `/nodes/${node}/qemu/${vmid}/agent/network-get-interfaces`);
const interfaces = agentData?.result ?? [];
for (const iface of interfaces) {
for (const addr of iface["ip-addresses"] ?? []) {
if (addr["ip-address-type"] === "ipv4" && addr["ip-address"] !== "127.0.0.1") {
ipAddresses.push(addr["ip-address"]);
}
}
}
} catch {
guestAgentAvailable = false;
}
}
const memoryBytes = typeof config.memory === "number" ? config.memory * 1024 * 1024 : null;
return {
vmid,
node,
type,
name: config.name ?? `${type}/${vmid}`,
status: status.status,
cpuCores,
cpuUsagePercent: typeof status.cpu === "number" ? status.cpu * 100 : null,
memoryBytes,
memUsedBytes: typeof status.mem === "number" ? status.mem : null,
diskBytes,
uptime: typeof status.uptime === "number" ? status.uptime : null,
ipAddresses,
guestAgentAvailable: type === "qemu" ? guestAgentAvailable : true,
};
}
async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> { async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> {
await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`); await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`);
} }
@@ -165,5 +278,5 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
} }
} }
return { ping, listGuests, startGuest, stopGuest, restartGuest }; return { ping, listGuests, getGuestDetail, startGuest, stopGuest, restartGuest };
} }
+9
View File
@@ -7,10 +7,18 @@ import { asyncHandler } from "../utils/asyncHandler.js";
export const agentReportRouter = Router(); export const agentReportRouter = Router();
const systemSchema = z.object({
ip_addresses: z.array(z.string()).optional(),
cpu: z.object({ model: z.string().optional(), cores: z.number().optional(), load_percent: z.number().nullable().optional() }).optional(),
memory: z.object({ total_bytes: z.number().optional(), used_bytes: z.number().optional() }).optional(),
disks: z.array(z.object({ mount: z.string(), size_bytes: z.number(), used_bytes: z.number() })).optional(),
});
const reportSchema = z.object({ const reportSchema = z.object({
hostname: z.string().max(255).optional(), hostname: z.string().max(255).optional(),
os_type: z.string().optional(), os_type: z.string().optional(),
reported_at: z.string().optional(), reported_at: z.string().optional(),
system: systemSchema.optional(),
tasks: z.array( tasks: z.array(
z.object({ z.object({
schedule_type: z.enum(["cron", "systemd_timer"]), schedule_type: z.enum(["cron", "systemd_timer"]),
@@ -47,6 +55,7 @@ agentReportRouter.post("/", asyncHandler(async (req, res) => {
await syncServerTasks(server.id, { await syncServerTasks(server.id, {
hostname: parsed.data.hostname, hostname: parsed.data.hostname,
system: parsed.data.system,
tasks: parsed.data.tasks.map((t) => ({ tasks: parsed.data.tasks.map((t) => ({
scheduleType: t.schedule_type, scheduleType: t.schedule_type,
name: t.name, name: t.name,
+153 -2
View File
@@ -1,12 +1,14 @@
import { Router } from "express"; import { Router } from "express";
import { eq } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { servers } from "../db/schema.js"; import { servers, integrations, dnsRecordsCache, dnsZonesCache, dnsProviders } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js"; import { requireAuth, requireRole } from "../auth/middleware.js";
import { generateApiToken } from "../services/tokens.js"; import { generateApiToken } from "../services/tokens.js";
import { recordAudit } from "../services/audit.js"; import { recordAudit } from "../services/audit.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js";
export const serversRouter = Router(); export const serversRouter = Router();
serversRouter.use(requireAuth); serversRouter.use(requireAuth);
@@ -85,6 +87,155 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async
res.json({ server: serverOut, token }); res.json({ server: serverOut, token });
})); }));
const updateServerSchema = z
.object({
name: z.string().min(1).max(100).optional(),
hostname: z.string().max(255).optional(),
description: z.string().max(500).optional(),
proxmoxIntegrationId: z.number().int().nullable().optional(),
proxmoxNode: z.string().nullable().optional(),
proxmoxGuestType: z.enum(["qemu", "lxc"]).nullable().optional(),
proxmoxVmid: z.number().int().nullable().optional(),
})
.refine(
(data) => {
const proxmoxFields = [data.proxmoxIntegrationId, data.proxmoxNode, data.proxmoxGuestType, data.proxmoxVmid];
if (proxmoxFields.every((f) => f === undefined)) return true;
const allNull = proxmoxFields.every((f) => f === null);
const allSet = proxmoxFields.every((f) => f !== undefined && f !== null);
return allNull || allSet;
},
{ message: "proxmoxIntegrationId/proxmoxNode/proxmoxGuestType/proxmoxVmid must be set together or all cleared to null" },
);
serversRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const parsed = updateServerSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [existing] = await db.select().from(servers).where(eq(servers.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
if (parsed.data.proxmoxIntegrationId) {
const [integration] = await db
.select()
.from(integrations)
.where(eq(integrations.id, parsed.data.proxmoxIntegrationId))
.limit(1);
if (!integration || integration.type !== "proxmox") {
return res.status(400).json({ error: "invalid_proxmox_integration" });
}
}
const [updated] = await db.update(servers).set(parsed.data).where(eq(servers.id, id)).returning();
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "update",
targetType: "server",
targetId: id,
detail: { name: updated.name },
});
const { apiTokenHash, ...serverOut } = updated;
res.json({ server: serverOut });
}));
serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const [server] = await db.select().from(servers).where(eq(servers.id, id)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" });
let ipAddresses: string[] = [];
let hardware: Record<string, unknown>;
if (server.proxmoxIntegrationId && server.proxmoxNode && server.proxmoxGuestType && server.proxmoxVmid !== null) {
const loaded = await loadIntegrationConfig(server.proxmoxIntegrationId);
if (!loaded || loaded.integration.type !== "proxmox") {
hardware = { source: "proxmox", error: "The linked Proxmox integration no longer exists or has changed type." };
} else {
try {
const adapter = createProxmoxAdapter(loaded.config as { url: string; tokenId: string; tokenSecret: string; insecure?: boolean });
const detail = await adapter.getGuestDetail(
server.proxmoxNode,
server.proxmoxGuestType as ProxmoxGuestType,
server.proxmoxVmid,
);
ipAddresses = detail.ipAddresses;
hardware = {
source: "proxmox",
integrationId: loaded.integration.id,
integrationName: loaded.integration.name,
status: detail.status,
cpuCores: detail.cpuCores,
cpuUsagePercent: detail.cpuUsagePercent,
memTotalBytes: detail.memoryBytes,
memUsedBytes: detail.memUsedBytes,
diskBytes: detail.diskBytes,
uptime: detail.uptime,
guestAgentAvailable: detail.guestAgentAvailable,
};
} catch (err) {
hardware = { source: "proxmox", error: err instanceof Error ? err.message : String(err) };
}
}
} else if (server.cpuModel || server.cpuCores || server.memTotalBytes || server.disks) {
ipAddresses = server.ipAddresses ? JSON.parse(server.ipAddresses) : [];
hardware = {
source: "agent",
cpuModel: server.cpuModel,
cpuCores: server.cpuCores,
cpuLoadPercent: server.cpuLoadPercent,
memTotalBytes: server.memTotalBytes,
memUsedBytes: server.memUsedBytes,
disks: server.disks ? JSON.parse(server.disks) : [],
};
} else {
hardware = { source: "none" };
}
let dnsMatches: { recordName: string; recordType: string; ip: string; zoneName: string | null; providerName: string; providerType: string }[] = [];
if (ipAddresses.length > 0) {
dnsMatches = await db
.select({
recordName: dnsRecordsCache.name,
recordType: dnsRecordsCache.type,
ip: dnsRecordsCache.content,
zoneName: dnsZonesCache.zoneName,
providerName: dnsProviders.name,
providerType: dnsProviders.providerType,
})
.from(dnsRecordsCache)
.innerJoin(dnsProviders, eq(dnsRecordsCache.providerId, dnsProviders.id))
.leftJoin(
dnsZonesCache,
and(eq(dnsZonesCache.providerId, dnsRecordsCache.providerId), eq(dnsZonesCache.zoneId, dnsRecordsCache.zoneId)),
)
.where(inArray(dnsRecordsCache.content, ipAddresses));
}
const {
apiTokenHash,
ipAddresses: _rawIpAddresses,
disks: _rawDisks,
cpuModel: _cpuModel,
cpuCores: _cpuCores,
cpuLoadPercent: _cpuLoadPercent,
memTotalBytes: _memTotalBytes,
memUsedBytes: _memUsedBytes,
...serverOut
} = server;
res.json({ server: serverOut, ipAddresses, hardware, dnsMatches });
}));
serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
+26 -1
View File
@@ -13,8 +13,16 @@ export interface IncomingTask {
metadata?: unknown; metadata?: unknown;
} }
export interface IncomingSystemInfo {
ip_addresses?: string[];
cpu?: { model?: string; cores?: number; load_percent?: number | null };
memory?: { total_bytes?: number; used_bytes?: number };
disks?: { mount: string; size_bytes: number; used_bytes: number }[];
}
export interface AgentReport { export interface AgentReport {
hostname?: string; hostname?: string;
system?: IncomingSystemInfo;
tasks: IncomingTask[]; tasks: IncomingTask[];
} }
@@ -85,8 +93,25 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
await db.update(scheduledTasks).set({ isStale: true }).where(agentScope); await db.update(scheduledTasks).set({ isStale: true }).where(agentScope);
} }
const system = report.system;
await db await db
.update(servers) .update(servers)
.set({ lastSeenAt: now, ...(report.hostname ? { hostname: report.hostname } : {}) }) .set({
lastSeenAt: now,
...(report.hostname ? { hostname: report.hostname } : {}),
...(system?.ip_addresses ? { ipAddresses: JSON.stringify(system.ip_addresses) } : {}),
...(system?.cpu?.model !== undefined ? { cpuModel: system.cpu.model } : {}),
...(system?.cpu?.cores !== undefined ? { cpuCores: system.cpu.cores } : {}),
...(system?.cpu?.load_percent !== undefined ? { cpuLoadPercent: system.cpu.load_percent } : {}),
...(system?.memory?.total_bytes !== undefined ? { memTotalBytes: system.memory.total_bytes } : {}),
...(system?.memory?.used_bytes !== undefined ? { memUsedBytes: system.memory.used_bytes } : {}),
...(system?.disks
? {
disks: JSON.stringify(
system.disks.map((d) => ({ mount: d.mount, sizeBytes: d.size_bytes, usedBytes: d.used_bytes })),
),
}
: {}),
})
.where(eq(servers.id, serverId)); .where(eq(servers.id, serverId));
} }
+2
View File
@@ -9,6 +9,7 @@ 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 ServersTasks from "./pages/ServersTasks";
import ServerDetail from "./pages/ServerDetail";
import Integrations from "./pages/Integrations"; import Integrations from "./pages/Integrations";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import NotificationSettings from "./pages/settings/NotificationSettings"; import NotificationSettings from "./pages/settings/NotificationSettings";
@@ -63,6 +64,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Dashboard user={user} />} /> <Route path="/" element={<Dashboard user={user} />} />
<Route path="/servers" element={<ServersTasks user={user} />} /> <Route path="/servers" element={<ServersTasks user={user} />} />
<Route path="/servers/:id" element={<ServerDetail 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} />} />
+51
View File
@@ -181,6 +181,54 @@ export interface ServerRecord {
apiTokenPrefix: string; apiTokenPrefix: string;
createdAt: string; createdAt: string;
lastSeenAt: string | null; lastSeenAt: string | null;
proxmoxIntegrationId: number | null;
proxmoxNode: string | null;
proxmoxGuestType: "qemu" | "lxc" | null;
proxmoxVmid: number | null;
}
export interface ServerHardware {
source: "proxmox" | "agent" | "none";
error?: string;
integrationId?: number;
integrationName?: string;
status?: string;
cpuModel?: string;
cpuCores?: number | null;
cpuUsagePercent?: number | null;
cpuLoadPercent?: number | null;
memTotalBytes?: number | null;
memUsedBytes?: number | null;
diskBytes?: number | null;
disks?: { mount: string; sizeBytes: number; usedBytes: number }[];
uptime?: number | null;
guestAgentAvailable?: boolean;
}
export interface DnsMatch {
recordName: string;
recordType: string;
ip: string;
zoneName: string | null;
providerName: string;
providerType: string;
}
export interface ServerDetail {
server: ServerRecord;
ipAddresses: string[];
hardware: ServerHardware;
dnsMatches: DnsMatch[];
}
export interface ServerUpdateInput {
name?: string;
hostname?: string;
description?: string;
proxmoxIntegrationId?: number | null;
proxmoxNode?: string | null;
proxmoxGuestType?: "qemu" | "lxc" | null;
proxmoxVmid?: number | null;
} }
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual"; export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual";
@@ -486,6 +534,9 @@ export const api = {
request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, { request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, {
method: "POST", method: "POST",
}), }),
update: (id: number, data: ServerUpdateInput) =>
request<{ server: ServerRecord }>(`/api/servers/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
detail: (id: number) => request<ServerDetail>(`/api/servers/${id}/detail`),
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }), remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
}, },
tasks: { tasks: {
+142
View File
@@ -0,0 +1,142 @@
import cronstrue from "cronstrue";
import { CronExpressionParser } from "cron-parser";
import type { TaskRecord } from "../api/client";
import { formatDateTime } from "../utils/date";
export 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",
};
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;
}
}
export default function ServerTaskTable({
tasks,
canEditTasks,
onEdit,
onDelete,
}: {
tasks: TaskRecord[];
canEditTasks: boolean;
onEdit: (task: TaskRecord) => void;
onDelete: (task: TaskRecord) => void;
}) {
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 (
<>
{[...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={() => onEdit(task)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => onDelete(task)}>
Delete
</button>
</div>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
</>
);
}
+559
View File
@@ -0,0 +1,559 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
api,
type CurrentUser,
type IntegrationSummary,
type ManualTaskInput,
type ProxmoxGuest,
type ScheduleType,
type ServerDetail as ServerDetailResponse,
type TaskRecord,
} from "../api/client";
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
import { formatDateTime } from "../utils/date";
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map(
([value, label]) => ({ value: value as ScheduleType, label }),
);
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return "—";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
function formatUptime(seconds: number | null | undefined): string {
if (!seconds) return "—";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
if (days > 0) return `${days}d ${hours}h`;
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
function usageBar(used: number | null | undefined, total: number | null | undefined) {
if (!total) return null;
const pct = Math.min(100, Math.round(((used ?? 0) / total) * 100));
return (
<div className="progress progress-sm mt-1">
<div className={`progress-bar ${pct > 90 ? "bg-red" : pct > 75 ? "bg-yellow" : "bg-green"}`} style={{ width: `${pct}%` }} />
</div>
);
}
const emptyTaskForm = {
scheduleType: "docker" as ScheduleType,
name: "",
command: "",
scheduleExpression: "",
enabled: true,
};
export default function ServerDetail({ user }: { user: CurrentUser }) {
const { id } = useParams<{ id: string }>();
const serverId = Number(id);
const isAdmin = user.role === "admin";
const canEditTasks = user.role === "admin" || user.role === "operator";
const [detail, setDetail] = useState<ServerDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [tasks, setTasks] = useState<TaskRecord[] | null>(null);
const [taskForm, setTaskForm] = useState<typeof emptyTaskForm | null>(null);
const [editingTask, setEditingTask] = useState<TaskRecord | null>(null);
const [savingTask, setSavingTask] = useState(false);
const [proxmoxIntegrations, setProxmoxIntegrations] = useState<IntegrationSummary[] | null>(null);
const [pickedIntegrationId, setPickedIntegrationId] = useState<number | null>(null);
const [proxmoxGuests, setProxmoxGuests] = useState<ProxmoxGuest[] | null>(null);
const [pickedGuestKey, setPickedGuestKey] = useState<string>("");
const [linking, setLinking] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const loadDetail = useCallback(() => {
setRefreshing(true);
return api.servers
.detail(serverId)
.then((res) => {
setDetail(res);
setError(null);
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => {
setLoading(false);
setRefreshing(false);
});
}, [serverId]);
const loadTasks = useCallback(() => {
api.tasks
.list({ serverId, includeStale: true })
.then((res) => setTasks(res.tasks))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [serverId]);
useEffect(() => {
loadDetail();
loadTasks();
}, [loadDetail, loadTasks]);
useEffect(() => {
if (!isAdmin) return;
api.integrations
.list()
.then((res) => setProxmoxIntegrations(res.integrations.filter((i) => i.type === "proxmox" && i.enabled)))
.catch(() => setProxmoxIntegrations([]));
}, [isAdmin]);
useEffect(() => {
if (!pickedIntegrationId) {
setProxmoxGuests(null);
return;
}
api.integrations.proxmox
.guests(pickedIntegrationId)
.then((res) => setProxmoxGuests(res.guests))
.catch(() => setProxmoxGuests([]));
}, [pickedIntegrationId]);
async function linkProxmox() {
if (!pickedIntegrationId || !pickedGuestKey) return;
const guest = proxmoxGuests?.find((g) => `${g.node}/${g.type}/${g.vmid}` === pickedGuestKey);
if (!guest) return;
setLinking(true);
setLinkError(null);
try {
await api.servers.update(serverId, {
proxmoxIntegrationId: pickedIntegrationId,
proxmoxNode: guest.node,
proxmoxGuestType: guest.type,
proxmoxVmid: guest.vmid,
});
await loadDetail();
} catch (err) {
setLinkError(err instanceof Error ? err.message : String(err));
} finally {
setLinking(false);
}
}
async function unlinkProxmox() {
setLinking(true);
setLinkError(null);
try {
await api.servers.update(serverId, {
proxmoxIntegrationId: null,
proxmoxNode: null,
proxmoxGuestType: null,
proxmoxVmid: null,
});
setPickedIntegrationId(null);
setPickedGuestKey("");
await loadDetail();
} catch (err) {
setLinkError(err instanceof Error ? err.message : String(err));
} finally {
setLinking(false);
}
}
function openAddTask() {
setEditingTask(null);
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 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);
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);
loadTasks();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
if (loading) return <div className="text-secondary">Loading…</div>;
if (error && !detail) return <div className="alert alert-danger">{error}</div>;
if (!detail) return null;
const { server, hardware, ipAddresses, dnsMatches } = detail;
return (
<>
<div className="mb-3">
<Link to="/servers" className="text-secondary">
&larr; Servers &amp; Tasks
</Link>
</div>
<div className="d-flex align-items-center mb-3">
<div>
<h2 className="page-title mb-0">{server.name}</h2>
{server.description && <div className="text-secondary">{server.description}</div>}
</div>
<button className="btn btn-outline-secondary ms-auto" onClick={loadDetail} disabled={refreshing}>
{refreshing ? "Refreshing…" : "Refresh"}
</button>
</div>
{error && <div className="alert alert-danger">{error}</div>}
{isAdmin && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Proxmox link</h3>
</div>
<div className="card-body">
{server.proxmoxIntegrationId ? (
<div className="d-flex align-items-center gap-3">
<span className="text-secondary">
Linked to {hardware.integrationName ?? "a Proxmox integration"} — {server.proxmoxGuestType}/{server.proxmoxVmid} on{" "}
{server.proxmoxNode}
</span>
<button className="btn btn-outline-danger btn-sm ms-auto" onClick={unlinkProxmox} disabled={linking}>
Unlink
</button>
</div>
) : (proxmoxIntegrations?.length ?? 0) === 0 ? (
<div className="text-secondary">No enabled Proxmox integrations configured yet.</div>
) : (
<div className="row g-2 align-items-end">
<div className="col-md-4">
<label className="form-label">Proxmox integration</label>
<select
className="form-select"
value={pickedIntegrationId ?? ""}
onChange={(e) => {
setPickedIntegrationId(e.target.value ? Number(e.target.value) : null);
setPickedGuestKey("");
}}
>
<option value="">Choose…</option>
{proxmoxIntegrations?.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</div>
<div className="col-md-5">
<label className="form-label">VM / LXC</label>
<select
className="form-select"
value={pickedGuestKey}
disabled={!proxmoxGuests}
onChange={(e) => setPickedGuestKey(e.target.value)}
>
<option value="">Choose…</option>
{proxmoxGuests?.map((g) => (
<option key={`${g.node}/${g.type}/${g.vmid}`} value={`${g.node}/${g.type}/${g.vmid}`}>
{g.name} ({g.type} #{g.vmid} on {g.node})
</option>
))}
</select>
</div>
<div className="col-md-3">
<button className="btn btn-primary w-100" onClick={linkProxmox} disabled={linking || !pickedGuestKey}>
{linking ? "Linking…" : "Link"}
</button>
</div>
{linkError && (
<div className="col-12">
<div className="alert alert-danger mb-0 mt-2">{linkError}</div>
</div>
)}
</div>
)}
</div>
</div>
)}
<div className="row row-cards mb-3">
<div className="col-lg-7">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Hardware &amp; Network</h3>
</div>
<div className="card-body">
{hardware.source === "none" && (
<div className="text-secondary">
No hardware data yet. Install the agent on this server, or link it to a Proxmox VM/LXC above.
</div>
)}
{hardware.error && <div className="alert alert-danger">{hardware.error}</div>}
{hardware.source === "proxmox" && !hardware.error && (
<>
<div className="text-secondary small mb-3">Synced live from Proxmox just now.</div>
<div className="row g-3">
<div className="col-sm-6">
<div className="text-secondary small">CPU</div>
<div>
{hardware.cpuCores ?? "—"} cores
{hardware.cpuUsagePercent !== null && hardware.cpuUsagePercent !== undefined
? ` · ${hardware.cpuUsagePercent.toFixed(1)}% used`
: ""}
</div>
</div>
<div className="col-sm-6">
<div className="text-secondary small">Memory</div>
<div>
{formatBytes(hardware.memUsedBytes)} / {formatBytes(hardware.memTotalBytes)}
</div>
{usageBar(hardware.memUsedBytes, hardware.memTotalBytes)}
</div>
<div className="col-sm-6">
<div className="text-secondary small">Disk (allocated)</div>
<div>{formatBytes(hardware.diskBytes)}</div>
</div>
<div className="col-sm-6">
<div className="text-secondary small">Uptime</div>
<div>{formatUptime(hardware.uptime)}</div>
</div>
</div>
{hardware.guestAgentAvailable === false && (
<div className="text-secondary small mt-3">
IP addresses unavailable — the QEMU guest agent isn't installed or running on this VM.
</div>
)}
</>
)}
{hardware.source === "agent" && (
<>
<div className="text-secondary small mb-3">
Reported by the agent as of {server.lastSeenAt ? formatDateTime(new Date(server.lastSeenAt)) : "unknown"}.
</div>
<div className="row g-3">
<div className="col-sm-6">
<div className="text-secondary small">CPU</div>
<div>
{hardware.cpuModel || "Unknown model"}
{hardware.cpuCores ? ` · ${hardware.cpuCores} cores` : ""}
</div>
{hardware.cpuLoadPercent !== null && hardware.cpuLoadPercent !== undefined && (
<div className="text-secondary small">Load: {hardware.cpuLoadPercent.toFixed(1)}%</div>
)}
</div>
<div className="col-sm-6">
<div className="text-secondary small">Memory</div>
<div>
{formatBytes(hardware.memUsedBytes)} / {formatBytes(hardware.memTotalBytes)}
</div>
{usageBar(hardware.memUsedBytes, hardware.memTotalBytes)}
</div>
</div>
{hardware.disks && hardware.disks.length > 0 && (
<div className="mt-3">
<div className="text-secondary small mb-1">Disks</div>
<table className="table table-sm table-vcenter mb-0">
<tbody>
{hardware.disks.map((d) => (
<tr key={d.mount}>
<td>{d.mount}</td>
<td>
{formatBytes(d.usedBytes)} / {formatBytes(d.sizeBytes)}
{usageBar(d.usedBytes, d.sizeBytes)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{ipAddresses.length > 0 && (
<div className="mt-3">
<div className="text-secondary small mb-1">IP addresses</div>
<ul className="list-unstyled mb-0">
{ipAddresses.map((ip) => {
const matches = dnsMatches.filter((m) => m.ip === ip);
return (
<li key={ip} className="mb-1">
<code>{ip}</code>
{matches.length > 0 && (
<span className="text-secondary">
{" "}
— {matches.map((m) => `${m.recordName} (${m.providerName})`).join(", ")}
</span>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
</div>
</div>
<div className="col-lg-5">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Identity</h3>
</div>
<div className="card-body">
<dl className="row mb-0">
<dt className="col-5">Hostname</dt>
<dd className="col-7">{server.hostname ?? "—"}</dd>
<dt className="col-5">OS type</dt>
<dd className="col-7">{server.osType}</dd>
<dt className="col-5">Token</dt>
<dd className="col-7 text-secondary">{server.apiTokenPrefix}…</dd>
<dt className="col-5">Created</dt>
<dd className="col-7">{formatDateTime(new Date(server.createdAt))}</dd>
<dt className="col-5">Last seen</dt>
<dd className="col-7">{server.lastSeenAt ? formatDateTime(new Date(server.lastSeenAt)) : "never"}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="d-flex align-items-center mb-3">
<h3 className="mb-0">Tasks</h3>
{canEditTasks && (
<button className="btn btn-primary btn-sm ms-auto" onClick={openAddTask}>
Add task
</button>
)}
</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">
<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-9">
<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>
)}
<div className="card">
{tasks && tasks.length === 0 && (
<div className="card-body text-secondary">No scheduled tasks found for this server yet.</div>
)}
{tasks && tasks.length > 0 && (
<ServerTaskTable tasks={tasks} canEditTasks={canEditTasks} onEdit={openEditTask} onDelete={deleteTask} />
)}
</div>
</>
);
}
+43 -133
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import cronstrue from "cronstrue"; import { Link } from "react-router-dom";
import { CronExpressionParser } from "cron-parser";
import { import {
api, api,
type CurrentUser, type CurrentUser,
@@ -10,18 +9,9 @@ import {
type TaskRecord, type TaskRecord,
} from "../api/client"; } from "../api/client";
import CopyButton from "../components/CopyButton"; import CopyButton from "../components/CopyButton";
import ServerTaskTable from "../components/ServerTaskTable";
import { formatDateTime } from "../utils/date"; 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 }[] = [ const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
{ value: "docker", label: "Docker" }, { value: "docker", label: "Docker" },
{ value: "cron", label: "Cron" }, { value: "cron", label: "Cron" },
@@ -40,34 +30,6 @@ function uninstallCommand(): string {
return `curl -fsSL ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`; 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 = { const emptyTaskForm = {
scheduleType: "docker" as ScheduleType, scheduleType: "docker" as ScheduleType,
name: "", name: "",
@@ -355,7 +317,9 @@ export default function ServersTasks({ user }: { user: CurrentUser }) {
<tbody> <tbody>
{servers.map((s) => ( {servers.map((s) => (
<tr key={s.id}> <tr key={s.id}>
<td>{s.name}</td> <td>
<Link to={`/servers/${s.id}`}>{s.name}</Link>
</td>
<td>{s.hostname ?? "—"}</td> <td>{s.hostname ?? "—"}</td>
<td className="text-secondary">{s.apiTokenPrefix}…</td> <td className="text-secondary">{s.apiTokenPrefix}…</td>
<td>{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"}</td> <td>{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"}</td>
@@ -388,6 +352,35 @@ export default function ServersTasks({ user }: { user: CurrentUser }) {
</> </>
) : ( ) : (
<> <>
{servers.length > 0 && (
<div className="row row-cards mb-3">
{servers.map((s) => (
<div className="col-sm-6 col-lg-3" key={s.id}>
<Link to={`/servers/${s.id}`} className="card card-sm text-reset text-decoration-none">
<div className="card-body">
<div className="d-flex align-items-center">
<div className="flex-fill">
<div className="font-weight-medium">{s.name}</div>
<div className="text-secondary small">{s.hostname ?? "no hostname reported"}</div>
</div>
{s.proxmoxIntegrationId ? (
<span className="badge bg-purple-lt text-purple">Proxmox</span>
) : s.lastSeenAt ? (
<span className="badge bg-green-lt text-green">Agent</span>
) : (
<span className="badge bg-secondary-lt text-secondary">No data</span>
)}
</div>
<div className="text-secondary small mt-2">
{s.lastSeenAt ? `Last seen ${formatDateTime(new Date(s.lastSeenAt))}` : "Never reported"}
</div>
</div>
</Link>
</div>
))}
</div>
)}
<div className="card mb-3"> <div className="card mb-3">
<div className="card-body"> <div className="card-body">
<div className="row g-2 align-items-end"> <div className="row g-2 align-items-end">
@@ -558,99 +551,16 @@ export default function ServersTasks({ user }: { user: CurrentUser }) {
</div> </div>
)} )}
{groups.map((group) => { {groups.map((group) => (
const byType = new Map<string, TaskRecord[]>(); <div className="card mb-3" key={group.serverId}>
for (const task of group.tasks) { <div className="card-header">
const list = byType.get(task.scheduleType) ?? []; <h3 className="card-title">
list.push(task); <Link to={`/servers/${group.serverId}`}>{group.serverName}</Link>
byType.set(task.scheduleType, list); </h3>
}
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> </div>
); <ServerTaskTable tasks={group.tasks} canEditTasks={canEditTasks} onEdit={openEditTask} onDelete={deleteTask} />
})} </div>
))}
</> </>
)} )}
</> </>