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:
1 parent
1f0e6a9d4c
commit
b9409d3095
15 files changed
+2206
-140
No files matched your search
@@ -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,
|
||||||
|
|||||||
@@ -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 .
|
||||||
|
|||||||
@@ -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,
|
"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
@@ -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", {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
@@ -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} />} />
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in new issue
Block a user