New integration, following the existing pattern: config in-app (URL + API key, credentials encrypted at rest), its own Uptime Kuma page, an Integrations list entry, a Dashboard widget, and diagnostic-log/ integration-down-alert coverage for free via the shared withDiagLogging wrapper. Read-only -- no start/stop equivalent exists for a monitor. Uptime Kuma has no conventional REST API (the dashboard talks to it over Socket.IO); researched before writing any code, since guessing wrong here would have cost real time. The one machine-readable, authenticated endpoint that lists every monitor is its Prometheus exporter at GET /metrics, gated by HTTP Basic auth -- an API key as the password with the username left blank on current installs, or the real dashboard login on installs from before the API-key feature existed. This adapter authenticates the same way and parses that endpoint's text-exposition format itself (metrics: monitor_status, monitor_response_time, monitor_cert_days_remaining, monitor_uptime_ratio; labels: monitor_id, monitor_name, monitor_type, monitor_url, monitor_hostname, monitor_port), verified against the documented metric/label set and the actual upstream source (server/prometheus.js). A malformed line is skipped rather than failing the whole scrape. "What server is being monitored for what": each monitor's target (an IP for TCP checks, or the hostname out of the URL for HTTP/keyword checks) is matched against your servers' own IPs and hostnames -- reusing the same kind of match already used in the consistency report -- and linked to that server's page. Monitors with no single network target (groups, push monitors, DNS/keyword checks with a complex URL) are left unmatched rather than guessed at. Uptime Kuma's tags aren't read, since the Prometheus endpoint doesn't reliably distinguish a tag label from any other label it might add later. The username field is the first genuinely optional integration config field this app has had; IntegrationField gained an `optional` flag (server validation and both the add/edit web forms honor it) rather than special-casing Uptime Kuma. Verified with 48 backend checks (Prometheus text parsing including escaped quotes, decimals, negative numbers, and malformed lines; TCP vs. HTTP target/port extraction; every documented status code; server matching by IP, hostname, and short name, including no-match cases; the route's real HTTP round trip against a fake Uptime Kuma server, wrong credentials, upstream failures, roles, wrong/disabled/missing integration, diagnostic-log entries; the optional-field validation rule) and by driving the real page and the real Dashboard widget in a browser against the real routers, including CSV export and column sorting. Real dev database mtime untouched. Not verified: a real Uptime Kuma instance. Everything here was checked against Uptime Kuma's documented metric format, its actual upstream source, and a fake server built to match both -- not against a live installation. If your instance's /metrics output differs from what's documented (older version, unusual monitor types), the parser should degrade to an empty or partial monitor list rather than error, but that degradation itself hasn't been observed against the real thing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
381 lines
17 KiB
TypeScript
381 lines
17 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
|
|
// ─── Users & roles ──────────────────────────────────────────────────────────
|
|
|
|
export const userRoles = ["admin", "operator", "viewer"] as const;
|
|
export type UserRole = (typeof userRoles)[number];
|
|
|
|
export const users = sqliteTable("users", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
oidcSub: text("oidc_sub").notNull().unique(),
|
|
email: text("email"),
|
|
name: text("name"),
|
|
role: text("role").$type<UserRole>().notNull().default("viewer"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
lastLoginAt: text("last_login_at"),
|
|
});
|
|
|
|
// ─── Audit log ──────────────────────────────────────────────────────────────
|
|
|
|
export const auditLog = sqliteTable("audit_log", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
actorUserId: integer("actor_user_id").references(() => users.id, { onDelete: "set null" }),
|
|
actorLabel: text("actor_label"), // denormalized name/email snapshot, survives user deletion
|
|
category: text("category").notNull(), // 'secret' | 'ipam' | 'dns' | 'user' | 'integration' | 'task' | ...
|
|
action: text("action").notNull(), // 'create' | 'update' | 'delete' | 'start' | 'stop' | ...
|
|
targetType: text("target_type"),
|
|
targetId: text("target_id"),
|
|
detail: text("detail"), // JSON-encoded free-form context
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── Diagnostic log — every outbound call to a DNS provider or integration ──
|
|
|
|
export const diagLog = sqliteTable("diag_log", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
source: text("source").notNull(), // e.g. 'cloudflare' | 'tailscale' | 'proxmox' | ...
|
|
operation: text("operation").notNull(), // adapter method name, e.g. 'listZones' | 'listDevices'
|
|
ok: integer("ok", { mode: "boolean" }).notNull(),
|
|
latencyMs: integer("latency_ms").notNull(),
|
|
error: text("error"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── Notification queue — pending notifications held during quiet hours ────
|
|
|
|
export const notificationQueue = sqliteTable("notification_queue", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
title: text("title").notNull(),
|
|
message: text("message").notNull(),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── Maintenance windows — alerts about a target are silenced until endsAt ──
|
|
|
|
export const maintenanceTargetTypes = ["server", "integration", "dns_provider"] as const;
|
|
export type MaintenanceTargetType = (typeof maintenanceTargetTypes)[number];
|
|
|
|
export const maintenanceWindows = sqliteTable("maintenance_windows", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
targetType: text("target_type").$type<MaintenanceTargetType>().notNull(),
|
|
targetId: integer("target_id").notNull(), // polymorphic — no FK; a deleted target's window is simply ignored
|
|
reason: text("reason"),
|
|
startedAt: text("started_at").notNull(), // ISO
|
|
endsAt: text("ends_at").notNull(), // ISO — required: a forgotten open-ended window would silence real problems forever
|
|
createdBy: text("created_by"),
|
|
});
|
|
|
|
// ─── Settings (key/value) ───────────────────────────────────────────────────
|
|
|
|
export const settings = sqliteTable("settings", {
|
|
key: text("key").primaryKey(),
|
|
value: text("value").notNull(),
|
|
updatedAt: text("updated_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── Secrets expiry tracker (ported from Sloth Manager) ────────────────────
|
|
|
|
export const secretTypes = ["api_token", "ssl_certificate", "password", "generic"] as const;
|
|
export type SecretType = (typeof secretTypes)[number];
|
|
|
|
export const secrets = sqliteTable("secrets", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
name: text("name").notNull(),
|
|
type: text("type").$type<SecretType>().notNull().default("generic"),
|
|
description: text("description"),
|
|
expiryDate: text("expiry_date").notNull(), // ISO date, e.g. 2026-03-01
|
|
warnDays: integer("warn_days").notNull().default(30),
|
|
notes: text("notes"),
|
|
// ssl_certificate secrets only: when set, expiryDate is read from the live certificate on this host:port instead of typed in.
|
|
checkHost: text("check_host"),
|
|
checkPort: integer("check_port"),
|
|
lastCheckedAt: text("last_checked_at"),
|
|
lastCheckError: text("last_check_error"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
updatedAt: text("updated_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── IPAM (ported from Sloth Manager) ───────────────────────────────────────
|
|
|
|
export const ipamEntries = sqliteTable("ipam_entries", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
ipAddress: text("ip_address").notNull().unique(),
|
|
label: text("label"),
|
|
vendor: text("vendor"),
|
|
location: text("location"),
|
|
notes: text("notes"),
|
|
source: text("source"), // null = entered manually; "tailscale" = auto-synced from a Tailscale integration
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
updatedAt: text("updated_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── Integration credentials (encrypted API tokens) ─────────────────────────
|
|
|
|
export const integrationCredentials = sqliteTable("integration_credentials", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
name: text("name").notNull(),
|
|
encryptedSecret: text("encrypted_secret").notNull(), // AES-256-GCM, see src/crypto.ts
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── DNS providers + record cache (ported from Sloth Manager) ──────────────
|
|
|
|
export const dnsProviderTypes = [
|
|
"cloudflare",
|
|
"loopia",
|
|
"pihole",
|
|
"azure",
|
|
"cpanel",
|
|
"technitium",
|
|
] as const;
|
|
export type DnsProviderType = (typeof dnsProviderTypes)[number];
|
|
|
|
export const dnsProviders = sqliteTable("dns_providers", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
providerType: text("provider_type").$type<DnsProviderType>().notNull(),
|
|
name: text("name").notNull(),
|
|
credentialId: integer("credential_id").references(() => integrationCredentials.id, {
|
|
onDelete: "set null",
|
|
}),
|
|
config: text("config"), // JSON: non-secret provider config (base URL, zone list, etc.)
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
export const dnsZonesCache = sqliteTable(
|
|
"dns_zones_cache",
|
|
{
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
providerId: integer("provider_id")
|
|
.notNull()
|
|
.references(() => dnsProviders.id, { onDelete: "cascade" }),
|
|
zoneId: text("zone_id").notNull(), // provider-specific zone identifier
|
|
zoneName: text("zone_name").notNull(),
|
|
syncedAt: text("synced_at"),
|
|
},
|
|
(table) => [uniqueIndex("dns_zones_cache_provider_zone_idx").on(table.providerId, table.zoneId)],
|
|
);
|
|
|
|
export const dnsRecordsCache = sqliteTable("dns_records_cache", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
providerId: integer("provider_id")
|
|
.notNull()
|
|
.references(() => dnsProviders.id, { onDelete: "cascade" }),
|
|
zoneId: text("zone_id").notNull(),
|
|
recordId: text("record_id").notNull(), // provider-specific record identifier
|
|
type: text("type").notNull(), // A, AAAA, CNAME, TXT, MX, ...
|
|
name: text("name").notNull(),
|
|
content: text("content").notNull(),
|
|
ttl: integer("ttl"),
|
|
priority: integer("priority"),
|
|
proxied: integer("proxied", { mode: "boolean" }),
|
|
});
|
|
|
|
// ─── 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(),
|
|
hostname: text("hostname"),
|
|
osType: text("os_type").notNull().default("linux"),
|
|
description: text("description"),
|
|
apiTokenHash: text("api_token_hash").notNull(),
|
|
apiTokenPrefix: text("api_token_prefix").notNull(),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
lastSeenAt: text("last_seen_at"),
|
|
|
|
// 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}[]
|
|
listeningPorts: text("listening_ports"), // JSON string: {protocol, port, address, process}[] — what the agent sees bound on the host
|
|
lastPortScan: text("last_port_scan"), // JSON string: summary of the most recent network scan from this app
|
|
tags: text("tags"), // JSON string: string[] — free-form labels for grouping and filtering, normalized by services/serverTags
|
|
|
|
// 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"),
|
|
|
|
// Not every server is a Proxmox guest (bare-metal boxes, other hosts) — an
|
|
// admin can hide the "Proxmox link" card on this server's detail page
|
|
// rather than seeing an irrelevant option on every server. Ignored (the
|
|
// card always shows) once a server IS actually linked, so unlinking stays
|
|
// reachable.
|
|
hideProxmoxLink: integer("hide_proxmox_link", { mode: "boolean" }).notNull().default(false),
|
|
});
|
|
|
|
export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
serverId: integer("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'windows_task' | 'docker' | 'backup' | 'update' | 'n8n_workflow' | 'manual'
|
|
origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync
|
|
name: text("name").notNull(),
|
|
command: text("command"),
|
|
scheduleExpression: text("schedule_expression"),
|
|
source: text("source"),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
nextRunAt: text("next_run_at"),
|
|
rawMetadata: text("raw_metadata"),
|
|
isStale: integer("is_stale", { mode: "boolean" }).notNull().default(false),
|
|
firstSeenAt: text("first_seen_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
lastSeenAt: text("last_seen_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── Server links — admin-page bookmarks per server (Dockge, Webmin, Cockpit, etc.) ─
|
|
|
|
export const serverLinks = sqliteTable("server_links", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
serverId: integer("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
label: text("label").notNull(),
|
|
url: text("url").notNull(),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── Live integrations (Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand) ─
|
|
|
|
export const integrationTypes = [
|
|
"proxmox",
|
|
"synology",
|
|
"semaphore",
|
|
"tailscale",
|
|
"gitea",
|
|
"dockhand",
|
|
"uptimekuma",
|
|
] as const;
|
|
export type IntegrationType = (typeof integrationTypes)[number];
|
|
|
|
export const integrations = sqliteTable("integrations", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
type: text("type").$type<IntegrationType>().notNull(),
|
|
name: text("name").notNull(),
|
|
baseUrl: text("base_url").notNull(),
|
|
credentialId: integer("credential_id").references(() => integrationCredentials.id, {
|
|
onDelete: "set null",
|
|
}),
|
|
config: text("config"), // JSON: per-type non-secret config (tailnet name, node name, etc.)
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// A port on a server that's either been seen open (by a scan or the agent) or that someone wrote a note about.
|
|
// Rows exist only while they carry information: an open port, or one with a label/comment ("reserved").
|
|
export const serverPorts = sqliteTable(
|
|
"server_ports",
|
|
{
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
serverId: integer("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
port: integer("port").notNull(),
|
|
protocol: text("protocol").$type<"tcp" | "udp">().notNull().default("tcp"),
|
|
label: text("label"),
|
|
comment: text("comment"),
|
|
// True when the last network scan connected to it. The agent's view is stored on the server row instead.
|
|
open: integer("open", { mode: "boolean" }).notNull().default(false),
|
|
lastSeenOpenAt: text("last_seen_open_at"),
|
|
updatedAt: text("updated_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
},
|
|
(t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)],
|
|
);
|
|
|
|
// ─── Domain registrations ───────────────────────────────────────────────────
|
|
|
|
export const domainOrigins = ["manual", "zone"] as const;
|
|
export type DomainOrigin = (typeof domainOrigins)[number];
|
|
|
|
// A registered domain whose expiry we track. "zone" rows are created from the DNS zones already synced from the
|
|
// providers (and removed again when the zone goes away); "manual" rows were typed in — for domains whose DNS lives elsewhere.
|
|
export const domains = sqliteTable("domains", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
name: text("name").notNull().unique(), // the registrable domain, lowercase ASCII
|
|
origin: text("origin").$type<DomainOrigin>().notNull().default("manual"),
|
|
expiresAt: text("expires_at"), // YYYY-MM-DD (UTC), as reported by the registry
|
|
registrar: text("registrar"),
|
|
lookupSource: text("lookup_source"), // "rdap" | "whois"
|
|
lastCheckedAt: text("last_checked_at"),
|
|
lastCheckedOkAt: text("last_checked_ok_at"),
|
|
lastCheckError: text("last_check_error"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── Consistency report ─────────────────────────────────────────────────────
|
|
|
|
// Findings someone has looked at and decided are fine (a DNS record that intentionally points elsewhere, a Docker bridge
|
|
// address, ...). Keyed by the finding's stable key so it stays ignored across runs.
|
|
export const consistencyIgnores = sqliteTable("consistency_ignores", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
key: text("key").notNull().unique(),
|
|
title: text("title").notNull(), // what the finding said when it was ignored, so the list still makes sense once it's gone
|
|
reason: text("reason"),
|
|
createdBy: text("created_by"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|
|
|
|
// ─── Tag catalogue ──────────────────────────────────────────────────────────
|
|
|
|
// Tags themselves live on the servers that carry them (servers.tags). A row here adds two things a server can't: a tag
|
|
// that exists before anything uses it (so it's offered when tagging), and a chosen colour. A defined tag is kept until an
|
|
// admin deletes it, even with no server using it; a tag with no row is simply one that's in use and has an automatic colour.
|
|
export const tagDefinitions = sqliteTable("tag_definitions", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
name: text("name").notNull().unique(), // normalised, as in services/serverTags
|
|
color: text("color"), // "#rrggbb"; null = automatic
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
});
|