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:
@@ -24,7 +24,11 @@ All modules from the original plan are built:
|
||||
vars) and their credentials are encrypted at rest
|
||||
- **Servers & Tasks** — cron/systemd tracking across Debian/Raspbian servers
|
||||
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,
|
||||
and authorize/deauthorize/remove actions; a live device-count widget.
|
||||
- **Integrations → Gitea** — repo list with each repo's last CI run status,
|
||||
|
||||
@@ -173,16 +173,67 @@ collect_systemd_timers() {
|
||||
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_systemd_timers
|
||||
collect_system_info
|
||||
|
||||
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 system "$SYSTEM_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
|
||||
echo "$PAYLOAD" | jq .
|
||||
|
||||
@@ -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
@@ -8,6 +8,13 @@
|
||||
"when": 1789419241921,
|
||||
"tag": "0000_colossal_violations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1789468464784,
|
||||
"tag": "0001_yummy_luke_cage",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+21
-1
@@ -1,5 +1,5 @@
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -150,6 +150,9 @@ export const dnsRecordsCache = sqliteTable("dns_records_cache", {
|
||||
|
||||
// ─── 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", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
@@ -162,6 +165,23 @@ export const servers = sqliteTable("servers", {
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
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", {
|
||||
|
||||
@@ -38,14 +38,54 @@ export interface ProxmoxGuest {
|
||||
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 {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
listGuests(): Promise<ProxmoxGuest[]>;
|
||||
getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise<ProxmoxGuestDetail>;
|
||||
startGuest(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>;
|
||||
}
|
||||
|
||||
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 {
|
||||
status: number;
|
||||
text: () => string;
|
||||
@@ -147,6 +187,79 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
|
||||
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> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -7,10 +7,18 @@ import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
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({
|
||||
hostname: z.string().max(255).optional(),
|
||||
os_type: z.string().optional(),
|
||||
reported_at: z.string().optional(),
|
||||
system: systemSchema.optional(),
|
||||
tasks: z.array(
|
||||
z.object({
|
||||
schedule_type: z.enum(["cron", "systemd_timer"]),
|
||||
@@ -47,6 +55,7 @@ agentReportRouter.post("/", asyncHandler(async (req, res) => {
|
||||
|
||||
await syncServerTasks(server.id, {
|
||||
hostname: parsed.data.hostname,
|
||||
system: parsed.data.system,
|
||||
tasks: parsed.data.tasks.map((t) => ({
|
||||
scheduleType: t.schedule_type,
|
||||
name: t.name,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
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 { generateApiToken } from "../services/tokens.js";
|
||||
import { recordAudit } from "../services/audit.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();
|
||||
serversRouter.use(requireAuth);
|
||||
@@ -85,6 +87,155 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async
|
||||
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) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
|
||||
@@ -13,8 +13,16 @@ export interface IncomingTask {
|
||||
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 {
|
||||
hostname?: string;
|
||||
system?: IncomingSystemInfo;
|
||||
tasks: IncomingTask[];
|
||||
}
|
||||
|
||||
@@ -85,8 +93,25 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
|
||||
await db.update(scheduledTasks).set({ isStale: true }).where(agentScope);
|
||||
}
|
||||
|
||||
const system = report.system;
|
||||
await db
|
||||
.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));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import Secrets from "./pages/Secrets";
|
||||
import Ipam from "./pages/Ipam";
|
||||
import Dns from "./pages/Dns";
|
||||
import ServersTasks from "./pages/ServersTasks";
|
||||
import ServerDetail from "./pages/ServerDetail";
|
||||
import Integrations from "./pages/Integrations";
|
||||
import Settings from "./pages/Settings";
|
||||
import NotificationSettings from "./pages/settings/NotificationSettings";
|
||||
@@ -63,6 +64,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard 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="/ipam" element={<Ipam user={user} />} />
|
||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||
|
||||
@@ -181,6 +181,54 @@ export interface ServerRecord {
|
||||
apiTokenPrefix: string;
|
||||
createdAt: string;
|
||||
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";
|
||||
@@ -486,6 +534,9 @@ export const api = {
|
||||
request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, {
|
||||
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" }),
|
||||
},
|
||||
tasks: {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← Servers & 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 & 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
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import cronstrue from "cronstrue";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
@@ -10,18 +9,9 @@ import {
|
||||
type TaskRecord,
|
||||
} from "../api/client";
|
||||
import CopyButton from "../components/CopyButton";
|
||||
import ServerTaskTable from "../components/ServerTaskTable";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
||||
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||
cron: "Cron jobs",
|
||||
systemd_timer: "systemd timers",
|
||||
docker: "Docker jobs",
|
||||
backup: "Backups",
|
||||
update: "Updates",
|
||||
n8n_workflow: "n8n workflows",
|
||||
manual: "Other",
|
||||
};
|
||||
|
||||
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
|
||||
{ value: "docker", label: "Docker" },
|
||||
{ value: "cron", label: "Cron" },
|
||||
@@ -40,34 +30,6 @@ function uninstallCommand(): string {
|
||||
return `curl -fsSL ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`;
|
||||
}
|
||||
|
||||
function describeSchedule(task: TaskRecord): string {
|
||||
if (!task.scheduleExpression) return "—";
|
||||
if (task.scheduleType === "cron") {
|
||||
try {
|
||||
return cronstrue.toString(task.scheduleExpression, { verbose: false });
|
||||
} catch {
|
||||
return task.scheduleExpression;
|
||||
}
|
||||
}
|
||||
return task.scheduleExpression;
|
||||
}
|
||||
|
||||
interface NextRun {
|
||||
date: Date;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
function computeNextRun(task: TaskRecord): NextRun | null {
|
||||
if (task.nextRunAt) return { date: new Date(task.nextRunAt), estimated: false };
|
||||
if (task.scheduleType !== "cron" || !task.scheduleExpression || task.isStale || !task.enabled) return null;
|
||||
try {
|
||||
const date = CronExpressionParser.parse(task.scheduleExpression).next().toDate();
|
||||
return { date, estimated: true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyTaskForm = {
|
||||
scheduleType: "docker" as ScheduleType,
|
||||
name: "",
|
||||
@@ -355,7 +317,9 @@ export default function ServersTasks({ user }: { user: CurrentUser }) {
|
||||
<tbody>
|
||||
{servers.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.name}</td>
|
||||
<td>
|
||||
<Link to={`/servers/${s.id}`}>{s.name}</Link>
|
||||
</td>
|
||||
<td>{s.hostname ?? "—"}</td>
|
||||
<td className="text-secondary">{s.apiTokenPrefix}…</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-body">
|
||||
<div className="row g-2 align-items-end">
|
||||
@@ -558,99 +551,16 @@ export default function ServersTasks({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => {
|
||||
const byType = new Map<string, TaskRecord[]>();
|
||||
for (const task of group.tasks) {
|
||||
const list = byType.get(task.scheduleType) ?? [];
|
||||
list.push(task);
|
||||
byType.set(task.scheduleType, list);
|
||||
}
|
||||
return (
|
||||
<div className="card mb-3" key={group.serverId}>
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{group.serverName}</h3>
|
||||
</div>
|
||||
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
|
||||
<div key={scheduleType}>
|
||||
<div className="card-body py-2 bg-secondary-lt">
|
||||
<strong>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</strong>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Command</th>
|
||||
<th>Schedule</th>
|
||||
<th>Next run</th>
|
||||
<th>Status</th>
|
||||
{canEditTasks && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupTasks.map((task) => {
|
||||
const nextRun = computeNextRun(task);
|
||||
return (
|
||||
<tr key={task.id}>
|
||||
<td>{task.name}</td>
|
||||
<td className="text-secondary">{task.command ?? "—"}</td>
|
||||
<td>
|
||||
<div>{describeSchedule(task)}</div>
|
||||
{task.scheduleExpression && (
|
||||
<span className="text-secondary small">{task.scheduleExpression}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{nextRun ? (
|
||||
<span
|
||||
title={
|
||||
nextRun.estimated
|
||||
? "Estimated from the cron expression in your browser's timezone — the server may run in a different timezone"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{nextRun.estimated ? "~" : ""}
|
||||
{formatDateTime(nextRun.date)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{task.isStale ? (
|
||||
<span className="badge bg-red-lt text-red me-1">Missing</span>
|
||||
) : task.enabled ? (
|
||||
<span className="badge bg-green-lt text-green me-1">Enabled</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary me-1">Disabled</span>
|
||||
)}
|
||||
{task.origin === "manual" && <span className="badge bg-blue-lt">Manual</span>}
|
||||
</td>
|
||||
{canEditTasks && (
|
||||
<td>
|
||||
{task.origin === "manual" && (
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm" onClick={() => openEditTask(task)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => deleteTask(task)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{groups.map((group) => (
|
||||
<div className="card mb-3" key={group.serverId}>
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<Link to={`/servers/${group.serverId}`}>{group.serverName}</Link>
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ServerTaskTable tasks={group.tasks} canEditTasks={canEditTasks} onEdit={openEditTask} onDelete={deleteTask} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user