New Settings > Tags tab (admin) listing every tag -- ones servers use and ones added ahead of time -- with how many servers carry each: - Add a tag before anything uses it, optionally with a colour. Such tags are offered as one-click "Add:" chips (and datalist suggestions) when tagging a server, so the same word gets spelled the same way everywhere. - Give any tag a colour of your choosing, in use or not, or reset it to the automatic one. Changes are drafted with Save/Cancel rather than saved as the picker drags. Colours show on the Servers page, its tag filter bar, the detail page and the editor, and update everywhere without a reload through one shared cached colour map. - Rename a tag; every server that has it is rewritten. Renaming to a name that already exists merges the two after a confirmation naming what will happen; the target keeps its own colour unless it had none, and a server carrying both ends up with one. - Delete a tag, which removes it from every server that has it, with a confirmation stating how many. Tags still live on the servers (servers.tags); a new tag_definitions table holds only what a server can't: existence before use, and a colour. A defined tag stays listed until an admin deletes it, even with no servers. Rename and delete change the servers and the catalogue in one transaction so they can't disagree. Only servers that actually carry the tag are rewritten and counted -- an earlier draft also counted servers whose tags merely weren't in sorted order, which the tests caught. Reading the list and colours is open to everyone signed in (needed to draw tags anywhere); changing the catalogue is admin-only, while tagging a server stays an operator action. Names go through the same normalisation as before, colours must be #rrggbb, and every change is audit-logged. New table tag_definitions (migration 0013). Verified with 44 backend checks (list/counts, roles, create/adopt/ duplicate/rejects, colour set/reset, rename incl. defined, undefined and unused tags, merge colour rules and both-sides servers, delete, audit, and that normal tagging still works afterwards) and in a browser against the real routers: add with colour, set/reset a colour and see it change on the Servers page live, merge with confirmation, delete, and the error path. Not clicked through: the quick-add chips inside the tag editor on a server's detail page (typechecked; same colour code as the rest). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1126 lines
37 KiB
TypeScript
1126 lines
37 KiB
TypeScript
export type UserRole = "admin" | "operator" | "viewer";
|
|
|
|
export interface CurrentUser {
|
|
id: number;
|
|
sub: string;
|
|
email?: string;
|
|
name?: string;
|
|
role: UserRole;
|
|
}
|
|
|
|
export interface UserRecord {
|
|
id: number;
|
|
oidcSub: string;
|
|
email: string | null;
|
|
name: string | null;
|
|
role: UserRole;
|
|
createdAt: string;
|
|
lastLoginAt: string | null;
|
|
}
|
|
|
|
export interface SessionSummary {
|
|
id: string;
|
|
sub: string;
|
|
email: string | null;
|
|
name: string | null;
|
|
role: UserRole | null;
|
|
ip: string | null;
|
|
userAgent: string | null;
|
|
lastAccess: string;
|
|
expiresAt: string | null;
|
|
}
|
|
|
|
export type MaintenanceTargetType = "server" | "integration" | "dns_provider";
|
|
|
|
export interface MaintenanceWindow {
|
|
id: number;
|
|
targetType: MaintenanceTargetType;
|
|
targetId: number;
|
|
targetName: string;
|
|
targetKind: string;
|
|
reason: string | null;
|
|
startedAt: string;
|
|
endsAt: string;
|
|
createdBy: string | null;
|
|
}
|
|
|
|
export interface MaintenanceTargets {
|
|
servers: { id: number; name: string }[];
|
|
integrations: { id: number; name: string; type: string }[];
|
|
dnsProviders: { id: number; name: string; providerType: string }[];
|
|
}
|
|
|
|
export interface AuditLogEntry {
|
|
id: number;
|
|
actorUserId: number | null;
|
|
actorLabel: string | null;
|
|
category: string;
|
|
action: string;
|
|
targetType: string | null;
|
|
targetId: string | null;
|
|
detail: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface DiagLogEntry {
|
|
id: number;
|
|
source: string;
|
|
operation: string;
|
|
ok: boolean;
|
|
latencyMs: number;
|
|
error: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface DiagLogResponse {
|
|
total: number;
|
|
entries: DiagLogEntry[];
|
|
}
|
|
|
|
export type SecretType = "api_token" | "ssl_certificate" | "password" | "generic";
|
|
export type SecretStatus = "ok" | "expiring" | "expired";
|
|
|
|
export interface SecretRecord {
|
|
id: number;
|
|
name: string;
|
|
type: SecretType;
|
|
description: string | null;
|
|
expiryDate: string;
|
|
warnDays: number;
|
|
notes: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
status: SecretStatus;
|
|
daysLeft: number;
|
|
/** ssl_certificate only — when set, expiryDate is read from the live certificate here rather than typed in. */
|
|
checkHost: string | null;
|
|
checkPort: number | null;
|
|
lastCheckedAt: string | null;
|
|
lastCheckError: string | null;
|
|
}
|
|
|
|
export interface SecretInput {
|
|
name: string;
|
|
type: SecretType;
|
|
description?: string;
|
|
/** Omitted when a host to check is given — the date then comes from the live certificate. */
|
|
expiryDate?: string;
|
|
warnDays: number;
|
|
notes?: string;
|
|
checkHost?: string | null;
|
|
checkPort?: number | null;
|
|
}
|
|
|
|
export interface IpamEntry {
|
|
id: number;
|
|
ipAddress: string;
|
|
label: string | null;
|
|
vendor: string | null;
|
|
location: string | null;
|
|
notes: string | null;
|
|
source: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
matchingDnsRecords: string[];
|
|
}
|
|
|
|
export interface IpamSyncResult {
|
|
added: number;
|
|
updated: number;
|
|
skipped: number;
|
|
skippedIps: string[];
|
|
errors: string[];
|
|
}
|
|
|
|
export interface IpamInput {
|
|
ipAddress: string;
|
|
label?: string;
|
|
vendor?: string;
|
|
location?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium";
|
|
|
|
export interface GotifySettings {
|
|
enabled: boolean;
|
|
url: string;
|
|
token: string;
|
|
priority: number;
|
|
}
|
|
|
|
export interface NtfySettings {
|
|
enabled: boolean;
|
|
url: string;
|
|
topic: string;
|
|
token: string;
|
|
priority: number;
|
|
}
|
|
|
|
export interface SmtpSettings {
|
|
enabled: boolean;
|
|
host: string;
|
|
port: number;
|
|
secure: boolean;
|
|
username: string;
|
|
password: string;
|
|
from: string;
|
|
to: string;
|
|
}
|
|
|
|
export interface WebhookSettings {
|
|
enabled: boolean;
|
|
url: string;
|
|
secret: string;
|
|
}
|
|
|
|
export interface NotificationEvents {
|
|
dnsAdd: boolean;
|
|
dnsUpdate: boolean;
|
|
dnsDelete: boolean;
|
|
secretCheck: boolean;
|
|
tailscaleKeyCheck: boolean;
|
|
dockerUpdateCheck: boolean;
|
|
proxmoxBackupCheck: boolean;
|
|
healthAlerts: boolean;
|
|
automationAlerts: boolean;
|
|
domainExpiryCheck: boolean;
|
|
secretCheckTime: string;
|
|
timezone: string;
|
|
integrationFailureAlerts: boolean;
|
|
integrationFailureThreshold: number;
|
|
}
|
|
|
|
export type DateFormat = "ymd" | "dmy" | "mdy";
|
|
export type TimeFormat = "24h" | "12h";
|
|
|
|
export interface DisplaySettings {
|
|
dateFormat: DateFormat;
|
|
timeFormat: TimeFormat;
|
|
pageSize: number;
|
|
}
|
|
|
|
export interface LogRetentionSettings {
|
|
enabled: boolean;
|
|
retentionDays: number;
|
|
intervalHours: number;
|
|
}
|
|
|
|
export interface HealthCheckSettings {
|
|
serverOfflineMinutes: number;
|
|
diskUsagePercent: number;
|
|
domainWarnDays: number;
|
|
}
|
|
|
|
export interface QuietHoursSettings {
|
|
enabled: boolean;
|
|
start: string;
|
|
end: string;
|
|
}
|
|
|
|
export interface AppSettings {
|
|
gotify: GotifySettings;
|
|
ntfy: NtfySettings;
|
|
smtp: SmtpSettings;
|
|
webhook: WebhookSettings;
|
|
notifications: NotificationEvents;
|
|
providerColors: Record<string, string>;
|
|
integrationColors: Record<string, string>;
|
|
display: DisplaySettings;
|
|
logRetention: LogRetentionSettings;
|
|
quietHours: QuietHoursSettings;
|
|
healthChecks: HealthCheckSettings;
|
|
}
|
|
|
|
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
|
|
|
export interface EncryptedExportFile {
|
|
app: "homelab-manager-backup";
|
|
version: 1;
|
|
salt: string;
|
|
iv: string;
|
|
authTag: string;
|
|
ciphertext: string;
|
|
}
|
|
|
|
export interface ImportResult {
|
|
integrationsCreated: number;
|
|
integrationsSkipped: string[];
|
|
dnsProvidersCreated: number;
|
|
dnsProvidersSkipped: string[];
|
|
}
|
|
|
|
export interface DnsProviderField {
|
|
key: string;
|
|
label: string;
|
|
secret: boolean;
|
|
type?: "text" | "password" | "checkbox";
|
|
placeholder?: string;
|
|
}
|
|
|
|
export interface DnsProviderSummary {
|
|
id: number;
|
|
providerType: DnsProviderType;
|
|
name: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface DnsZone {
|
|
id: string;
|
|
name: string;
|
|
syncedAt: string | null;
|
|
recordCount: number;
|
|
}
|
|
|
|
export interface DnsRecord {
|
|
id: string;
|
|
type: string;
|
|
name: string;
|
|
content: string;
|
|
ttl: number | null;
|
|
priority: number | null;
|
|
proxied?: boolean | null;
|
|
}
|
|
|
|
export interface DnsRecordInput {
|
|
type: string;
|
|
name: string;
|
|
content: string;
|
|
ttl?: number;
|
|
priority?: number;
|
|
proxied?: boolean;
|
|
}
|
|
|
|
export interface ServerRecord {
|
|
id: number;
|
|
name: string;
|
|
hostname: string | null;
|
|
osType: string;
|
|
description: string | null;
|
|
apiTokenPrefix: string;
|
|
createdAt: string;
|
|
lastSeenAt: string | null;
|
|
proxmoxIntegrationId: number | null;
|
|
proxmoxNode: string | null;
|
|
proxmoxGuestType: "qemu" | "lxc" | null;
|
|
proxmoxVmid: number | null;
|
|
hideProxmoxLink: boolean;
|
|
tags: string[];
|
|
}
|
|
|
|
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 ServerLink {
|
|
id: number;
|
|
label: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface ServerDetail {
|
|
server: ServerRecord;
|
|
ipAddresses: string[];
|
|
hardware: ServerHardware;
|
|
dnsMatches: DnsMatch[];
|
|
links: ServerLink[];
|
|
}
|
|
|
|
export interface PortEntry {
|
|
/** Null for a port only known from the agent that has no note yet. */
|
|
id: number | null;
|
|
port: number;
|
|
protocol: "tcp" | "udp";
|
|
label: string | null;
|
|
comment: string | null;
|
|
/** The last scan from this app connected to it. */
|
|
scanOpen: boolean;
|
|
lastSeenOpenAt: string | null;
|
|
agent: { addresses: string[]; process: string | null; localOnly: boolean } | null;
|
|
state: "open" | "reserved";
|
|
}
|
|
|
|
export interface PortScanSummary {
|
|
at: string;
|
|
address: string;
|
|
from: number;
|
|
to: number;
|
|
open: number;
|
|
refused: number;
|
|
filtered: number;
|
|
responded: boolean;
|
|
}
|
|
|
|
export interface PortList {
|
|
ports: PortEntry[];
|
|
agentReporting: boolean;
|
|
agentReportedAt: string | null;
|
|
lastScan: PortScanSummary | null;
|
|
}
|
|
|
|
export interface PortScanResult extends Omit<PortList, "lastScan"> {
|
|
scan: PortScanSummary;
|
|
freeCount: number;
|
|
freeRanges: [number, number][];
|
|
}
|
|
|
|
export interface ServerUpdateInput {
|
|
name?: string;
|
|
hostname?: string;
|
|
description?: string;
|
|
proxmoxIntegrationId?: number | null;
|
|
proxmoxNode?: string | null;
|
|
proxmoxGuestType?: "qemu" | "lxc" | null;
|
|
proxmoxVmid?: number | null;
|
|
hideProxmoxLink?: boolean;
|
|
}
|
|
|
|
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual";
|
|
|
|
export interface TaskRecord {
|
|
id: number;
|
|
serverId: number;
|
|
serverName: string;
|
|
scheduleType: ScheduleType;
|
|
origin: "agent" | "manual";
|
|
name: string;
|
|
command: string | null;
|
|
scheduleExpression: string | null;
|
|
source: string | null;
|
|
enabled: boolean;
|
|
nextRunAt: string | null;
|
|
isStale: boolean;
|
|
firstSeenAt: string;
|
|
lastSeenAt: string;
|
|
}
|
|
|
|
export interface ManualTaskInput {
|
|
serverId: number;
|
|
scheduleType: ScheduleType;
|
|
name: string;
|
|
command?: string;
|
|
scheduleExpression?: string;
|
|
nextRunAt?: string;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand";
|
|
|
|
export interface IntegrationField {
|
|
key: string;
|
|
label: string;
|
|
secret: boolean;
|
|
type?: "text" | "password" | "checkbox";
|
|
placeholder?: string;
|
|
}
|
|
|
|
export interface IntegrationSummary {
|
|
id: number;
|
|
type: IntegrationType;
|
|
name: string;
|
|
baseUrl: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface TailscaleDevice {
|
|
id: string;
|
|
nodeId: string;
|
|
hostname: string;
|
|
label: string;
|
|
addresses: string[];
|
|
primaryAddress: string;
|
|
os: string;
|
|
lastSeen: string | null;
|
|
isExitNode: boolean;
|
|
authorized: boolean;
|
|
online: boolean;
|
|
keyExpiry: string | null;
|
|
keyExpiryDisabled: boolean;
|
|
}
|
|
|
|
export interface TailscaleDevicesResponse {
|
|
devices: TailscaleDevice[];
|
|
summary: { total: number; online: number; unauthorized: number; expiringSoon: number };
|
|
}
|
|
|
|
export interface GiteaWorkflowRun {
|
|
id: number;
|
|
displayTitle: string;
|
|
status: string;
|
|
conclusion: string | null;
|
|
headBranch: string;
|
|
event: string;
|
|
runNumber: number;
|
|
htmlUrl: string;
|
|
startedAt: string | null;
|
|
completedAt: string | null;
|
|
}
|
|
|
|
export interface GiteaRepo {
|
|
owner: string;
|
|
name: string;
|
|
fullName: string;
|
|
htmlUrl: string;
|
|
private: boolean;
|
|
hasActions: boolean;
|
|
updatedAt: string;
|
|
latestRun: GiteaWorkflowRun | null;
|
|
}
|
|
|
|
export interface DomainRecord {
|
|
id: number;
|
|
name: string;
|
|
origin: "manual" | "zone";
|
|
expiresAt: string | null;
|
|
registrar: string | null;
|
|
lookupSource: "rdap" | "whois" | null;
|
|
lastCheckedAt: string | null;
|
|
lastCheckedOkAt: string | null;
|
|
lastCheckError: string | null;
|
|
createdAt: string;
|
|
status: "ok" | "expiring" | "expired" | "unknown";
|
|
daysLeft: number | null;
|
|
}
|
|
|
|
export interface DomainList {
|
|
domains: DomainRecord[];
|
|
warnDays: number;
|
|
checking: boolean;
|
|
}
|
|
|
|
export type ConsistencyKind = "ip_conflict" | "dns_stale" | "ipam_stale" | "not_in_ipam" | "no_dns";
|
|
export type ConsistencySeverity = "error" | "warning" | "info";
|
|
|
|
export interface ConsistencyFinding {
|
|
key: string;
|
|
kind: ConsistencyKind;
|
|
severity: ConsistencySeverity;
|
|
title: string;
|
|
detail: string;
|
|
ip: string | null;
|
|
servers: { id: number; name: string }[];
|
|
dnsNames: string[];
|
|
suggestedLabel: string | null;
|
|
}
|
|
|
|
export interface ConsistencyIgnore {
|
|
id: number;
|
|
key: string;
|
|
title: string;
|
|
reason: string | null;
|
|
createdBy: string | null;
|
|
createdAt: string;
|
|
stillPresent: boolean;
|
|
}
|
|
|
|
export interface ConsistencyReport {
|
|
findings: ConsistencyFinding[];
|
|
counts: Record<ConsistencySeverity, number>;
|
|
ignored: ConsistencyIgnore[];
|
|
excludedRanges: string[];
|
|
hiddenAddresses: number;
|
|
sources: {
|
|
servers: { total: number; withAddresses: number };
|
|
ipam: number;
|
|
dns: { zones: number; syncedZones: number; records: number; oldestSyncedAt: string | null; newestSyncedAt: string | null };
|
|
};
|
|
generatedAt: string;
|
|
}
|
|
|
|
export interface PrivacyOverview {
|
|
me: {
|
|
user: { email: string | null; name: string | null; role: UserRole; subject: string; createdAt: string; lastLoginAt: string | null };
|
|
auditEntries: number;
|
|
sessions: { ip: string | null; userAgent: string | null; lastAccess: string; expiresAt: string | null; current: boolean }[];
|
|
};
|
|
retention: { enabled: boolean; retentionDays: number; intervalHours: number };
|
|
outbound: {
|
|
integrationTypes: string[];
|
|
dnsProviderTypes: string[];
|
|
domainsTracked: number;
|
|
tlsCertificateChecks: number;
|
|
servers: number;
|
|
serversWithAgent: number;
|
|
channels: Record<"gotify" | "ntfy" | "smtp" | "webhook", { enabled: boolean; host: string | null }>;
|
|
};
|
|
}
|
|
|
|
export interface TagEntry {
|
|
name: string;
|
|
/** "#rrggbb", or null for the automatic colour. */
|
|
color: string | null;
|
|
/** Servers currently carrying it. */
|
|
count: number;
|
|
}
|
|
|
|
export interface DockhandContainer {
|
|
id: string;
|
|
name: string;
|
|
image: string;
|
|
state: string;
|
|
status: string;
|
|
environmentId: number;
|
|
environmentName: string;
|
|
updateAvailable: boolean | null;
|
|
newerVersion: string | null;
|
|
checkedAt: string | null;
|
|
}
|
|
|
|
export interface DockhandContainersResponse {
|
|
containers: DockhandContainer[];
|
|
summary: { total: number; running: number };
|
|
}
|
|
|
|
export type SemaphoreTaskStatus =
|
|
| "waiting"
|
|
| "starting"
|
|
| "waiting_confirmation"
|
|
| "confirmed"
|
|
| "rejected"
|
|
| "running"
|
|
| "stopping"
|
|
| "stopped"
|
|
| "success"
|
|
| "error";
|
|
|
|
export interface SemaphoreTask {
|
|
id: number;
|
|
status: SemaphoreTaskStatus;
|
|
created: string | null;
|
|
start: string | null;
|
|
end: string | null;
|
|
}
|
|
|
|
export interface SemaphoreTemplate {
|
|
id: number;
|
|
projectId: number;
|
|
projectName: string;
|
|
name: string;
|
|
playbook: string;
|
|
lastTask: SemaphoreTask | null;
|
|
}
|
|
|
|
export interface SemaphoreTemplatesResponse {
|
|
templates: SemaphoreTemplate[];
|
|
summary: { total: number; failing: number };
|
|
}
|
|
|
|
export type ProxmoxGuestType = "qemu" | "lxc";
|
|
|
|
export interface ProxmoxGuest {
|
|
vmid: number;
|
|
name: string;
|
|
node: string;
|
|
type: ProxmoxGuestType;
|
|
status: string;
|
|
cpu: number | null;
|
|
maxmem: number | null;
|
|
mem: number | null;
|
|
uptime: number | null;
|
|
}
|
|
|
|
export interface ProxmoxGuestsResponse {
|
|
guests: ProxmoxGuest[];
|
|
summary: { total: number; running: number };
|
|
}
|
|
|
|
export interface ProxmoxStorage {
|
|
id: string;
|
|
type: string;
|
|
active: boolean;
|
|
shared: boolean;
|
|
totalBytes: number | null;
|
|
usedBytes: number | null;
|
|
availBytes: number | null;
|
|
}
|
|
|
|
export interface ProxmoxNodeStats {
|
|
node: string;
|
|
error: string | null;
|
|
uptime: number | null;
|
|
cpuUsagePercent: number | null;
|
|
cpuCores: number | null;
|
|
loadAverage: [number, number, number] | null;
|
|
memTotalBytes: number | null;
|
|
memUsedBytes: number | null;
|
|
swapTotalBytes: number | null;
|
|
swapUsedBytes: number | null;
|
|
rootfsTotalBytes: number | null;
|
|
rootfsUsedBytes: number | null;
|
|
pveVersion: string | null;
|
|
storages: ProxmoxStorage[];
|
|
}
|
|
|
|
export interface ProxmoxNodesResponse {
|
|
nodes: ProxmoxNodeStats[];
|
|
}
|
|
|
|
export interface ProxmoxBackupJob {
|
|
id: string;
|
|
enabled: boolean;
|
|
schedule: string;
|
|
storage: string;
|
|
node: string | null;
|
|
allGuests: boolean;
|
|
vmids: string | null;
|
|
exclude: string | null;
|
|
}
|
|
|
|
export interface ProxmoxBackupTask {
|
|
node: string;
|
|
upid: string;
|
|
guestId: string | null;
|
|
status: string;
|
|
ok: boolean;
|
|
startTime: string;
|
|
endTime: string | null;
|
|
}
|
|
|
|
export interface ProxmoxBackupsResponse {
|
|
jobs: ProxmoxBackupJob[];
|
|
tasks: ProxmoxBackupTask[];
|
|
uncoveredGuests: ProxmoxGuest[];
|
|
}
|
|
|
|
export interface SynologyVolume {
|
|
id: string;
|
|
status: string;
|
|
deviceType: string;
|
|
sizeTotal: number | null;
|
|
sizeUsed: number | null;
|
|
}
|
|
|
|
export interface SynologyDisk {
|
|
id: string;
|
|
name: string;
|
|
device: string;
|
|
status: string;
|
|
smartStatus: string;
|
|
temp: number | null;
|
|
exceedBadSectorThreshold: boolean;
|
|
belowRemainLifeThreshold: boolean;
|
|
}
|
|
|
|
export interface SynologyStorageResponse {
|
|
volumes: SynologyVolume[];
|
|
disks: SynologyDisk[];
|
|
summary: { volumeCount: number; volumesNotNormal: number; diskCount: number; disksNotNormal: number };
|
|
}
|
|
|
|
export interface SynologySystemInfo {
|
|
hostname: string | null;
|
|
model: string | null;
|
|
serial: string | null;
|
|
firmwareVersion: string | null;
|
|
uptime: string | null;
|
|
ipAddresses: string[];
|
|
cpu: { cores: number | null; clockSpeedMHz: number | null; loadPercent: number | null };
|
|
memory: { totalBytes: number | null; usedBytes: number | null };
|
|
}
|
|
|
|
export interface SearchResponse {
|
|
servers: { id: number; name: string; hostname: string | null }[];
|
|
secrets: { id: number; name: string; type: SecretType }[];
|
|
ipam: { id: number; ipAddress: string; label: string | null }[];
|
|
integrations: { id: number; type: IntegrationType; name: string }[];
|
|
dnsProviders: { id: number; providerType: DnsProviderType; name: string }[];
|
|
dnsZones: { providerId: number; zoneId: string; zoneName: string; providerName: string }[];
|
|
dnsRecords: {
|
|
providerId: number;
|
|
zoneId: string;
|
|
zoneName: string;
|
|
type: string;
|
|
name: string;
|
|
content: string;
|
|
providerName: string;
|
|
}[];
|
|
}
|
|
|
|
export class UnauthorizedError extends Error {}
|
|
export class ForbiddenError extends Error {}
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(path, {
|
|
...init,
|
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
|
credentials: "same-origin",
|
|
});
|
|
if (res.status === 401) {
|
|
throw new UnauthorizedError("unauthorized");
|
|
}
|
|
if (res.status === 403) {
|
|
throw new ForbiddenError("forbidden");
|
|
}
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`Request failed (${res.status}): ${body}`);
|
|
}
|
|
if (res.status === 204) return undefined as T;
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export const api = {
|
|
me: () => request<{ user: CurrentUser }>("/api/me"),
|
|
search: {
|
|
query: (q: string) => request<SearchResponse>(`/api/search?q=${encodeURIComponent(q)}`),
|
|
},
|
|
users: {
|
|
list: () => request<{ users: UserRecord[] }>("/api/users"),
|
|
updateRole: (id: number, role: UserRole) =>
|
|
request<{ user: UserRecord }>(`/api/users/${id}/role`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ role }),
|
|
}),
|
|
},
|
|
maintenance: {
|
|
list: () => request<{ windows: MaintenanceWindow[]; targets: MaintenanceTargets }>("/api/maintenance"),
|
|
start: (data: { targetType: MaintenanceTargetType; targetId: number; minutes: number; reason?: string }) =>
|
|
request<{ window: MaintenanceWindow }>("/api/maintenance", { method: "POST", body: JSON.stringify(data) }),
|
|
end: (id: number) => request<void>(`/api/maintenance/${id}`, { method: "DELETE" }),
|
|
},
|
|
sessions: {
|
|
list: () => request<{ sessions: SessionSummary[]; currentSessionId: string }>("/api/sessions"),
|
|
revoke: (id: string) => request<void>(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
|
},
|
|
auditLog: {
|
|
list: (limit = 200) => request<{ entries: AuditLogEntry[] }>(`/api/audit-log?limit=${limit}`),
|
|
},
|
|
diagLog: {
|
|
list: (params: { source?: string; ok?: boolean; limit?: number; offset?: number } = {}) => {
|
|
const qs = new URLSearchParams();
|
|
if (params.source) qs.set("source", params.source);
|
|
if (params.ok !== undefined) qs.set("ok", String(params.ok));
|
|
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
|
if (params.offset !== undefined) qs.set("offset", String(params.offset));
|
|
const query = qs.toString();
|
|
return request<DiagLogResponse>(`/api/diag-log${query ? `?${query}` : ""}`);
|
|
},
|
|
clear: () => request<void>("/api/diag-log", { method: "DELETE" }),
|
|
},
|
|
secrets: {
|
|
list: () => request<{ secrets: SecretRecord[] }>("/api/secrets"),
|
|
create: (data: SecretInput) =>
|
|
request<{ secret: SecretRecord }>("/api/secrets", { method: "POST", body: JSON.stringify(data) }),
|
|
update: (id: number, data: Partial<SecretInput>) =>
|
|
request<{ secret: SecretRecord }>(`/api/secrets/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
remove: (id: number) => request<void>(`/api/secrets/${id}`, { method: "DELETE" }),
|
|
checkTls: (id: number) =>
|
|
request<{ secret: SecretRecord; result: { ok: boolean; expiryDate?: string; error?: string } }>(
|
|
`/api/secrets/${id}/check-tls`,
|
|
{ method: "POST" },
|
|
),
|
|
},
|
|
ipam: {
|
|
list: () => request<{ entries: IpamEntry[] }>("/api/ipam"),
|
|
create: (data: IpamInput) =>
|
|
request<{ entry: IpamEntry }>("/api/ipam", { method: "POST", body: JSON.stringify(data) }),
|
|
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) =>
|
|
request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
|
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
|
|
syncTailscale: () => request<IpamSyncResult>("/api/ipam/sync-tailscale", { method: "POST" }),
|
|
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
|
|
},
|
|
dns: {
|
|
providerFields: () =>
|
|
request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/api/dns/provider-fields"),
|
|
stats: () =>
|
|
request<{
|
|
totalProviders: number;
|
|
enabledProviders: number;
|
|
totalZones: number;
|
|
perProvider: { providerId: number; name: string; providerType: DnsProviderType; zoneCount: number; error: string | null }[];
|
|
totalRecords: number;
|
|
recordsByType: { type: string; count: number }[];
|
|
}>("/api/dns/stats"),
|
|
providers: {
|
|
list: () => request<{ providers: DnsProviderSummary[] }>("/api/dns/providers"),
|
|
create: (data: { providerType: DnsProviderType; name: string; config: Record<string, string | boolean> }) =>
|
|
request<{ provider: DnsProviderSummary }>("/api/dns/providers", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
update: (id: number, data: { name?: string; enabled?: boolean; config?: Record<string, string | boolean> }) =>
|
|
request<{ provider: DnsProviderSummary }>(`/api/dns/providers/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
remove: (id: number) => request<void>(`/api/dns/providers/${id}`, { method: "DELETE" }),
|
|
test: (data: { providerType: DnsProviderType; config: Record<string, string | boolean> }) =>
|
|
request<{ ok: boolean; zoneCount?: number; error?: string }>("/api/dns/providers/test", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
},
|
|
zones: {
|
|
list: (providerId: number) => request<{ zones: DnsZone[] }>(`/api/dns/providers/${providerId}/zones`),
|
|
},
|
|
records: {
|
|
list: (providerId: number, zoneId: string) =>
|
|
request<{ records: DnsRecord[]; syncedAt: string | null }>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`,
|
|
),
|
|
sync: (providerId: number, zoneId: string, zoneName?: string) =>
|
|
request<{ records: DnsRecord[]; syncedAt: string }>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/sync`,
|
|
{ method: "POST", body: JSON.stringify({ zoneName }) },
|
|
),
|
|
create: (providerId: number, zoneId: string, data: DnsRecordInput) =>
|
|
request<{ record: DnsRecord }>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`,
|
|
{ method: "POST", body: JSON.stringify(data) },
|
|
),
|
|
update: (providerId: number, zoneId: string, recordId: string, data: DnsRecordInput) =>
|
|
request<{ record: DnsRecord }>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`,
|
|
{ method: "PUT", body: JSON.stringify(data) },
|
|
),
|
|
remove: (providerId: number, zoneId: string, recordId: string) =>
|
|
request<void>(
|
|
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
},
|
|
clearCache: () => request<{ ok: true }>("/api/dns/cache/clear", { method: "POST" }),
|
|
},
|
|
servers: {
|
|
list: () => request<{ servers: ServerRecord[] }>("/api/servers"),
|
|
create: (data: { name: string; hostname?: string; description?: string }) =>
|
|
request<{ server: ServerRecord; token: string }>("/api/servers", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
rotateToken: (id: number) =>
|
|
request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, {
|
|
method: "POST",
|
|
}),
|
|
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" }),
|
|
setTags: (id: number, tags: string[]) =>
|
|
request<{ tags: string[] }>(`/api/servers/${id}/tags`, { method: "PUT", body: JSON.stringify({ tags }) }),
|
|
addLink: (id: number, data: { label: string; url: string }) =>
|
|
request<{ link: ServerLink }>(`/api/servers/${id}/links`, { method: "POST", body: JSON.stringify(data) }),
|
|
updateLink: (id: number, linkId: number, data: { label?: string; url?: string }) =>
|
|
request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }),
|
|
removeLink: (id: number, linkId: number) =>
|
|
request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }),
|
|
ports: {
|
|
list: (id: number) => request<PortList>(`/api/servers/${id}/ports`),
|
|
scan: (id: number, data: { address: string; from: number; to: number }) =>
|
|
request<PortScanResult>(`/api/servers/${id}/ports/scan`, { method: "POST", body: JSON.stringify(data) }),
|
|
save: (id: number, data: { port: number; protocol: "tcp" | "udp"; label?: string; comment?: string }) =>
|
|
request<{ ports: PortEntry[] }>(`/api/servers/${id}/ports`, { method: "PUT", body: JSON.stringify(data) }),
|
|
remove: (id: number, portId: number) => request<void>(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }),
|
|
},
|
|
},
|
|
tags: {
|
|
list: () => request<{ tags: TagEntry[] }>("/api/tags"),
|
|
create: (name: string, color?: string) =>
|
|
request<{ tags: TagEntry[] }>("/api/tags", { method: "POST", body: JSON.stringify({ name, color }) }),
|
|
setColor: (name: string, color: string | null) =>
|
|
request<{ tags: TagEntry[] }>("/api/tags/color", { method: "PUT", body: JSON.stringify({ name, color }) }),
|
|
rename: (from: string, to: string) =>
|
|
request<{ tags: TagEntry[]; updatedServers: number; merged: boolean }>("/api/tags/rename", { method: "POST", body: JSON.stringify({ from, to }) }),
|
|
remove: (name: string) =>
|
|
request<{ tags: TagEntry[]; updatedServers: number }>("/api/tags/delete", { method: "POST", body: JSON.stringify({ name }) }),
|
|
},
|
|
privacy: {
|
|
overview: () => request<PrivacyOverview>("/api/privacy"),
|
|
exportOwnData: async (): Promise<Blob> => {
|
|
const res = await fetch("/api/privacy/export", { credentials: "same-origin" });
|
|
if (!res.ok) throw new Error(`Request failed (${res.status}): ${await res.text().catch(() => "")}`);
|
|
return res.blob();
|
|
},
|
|
},
|
|
consistency: {
|
|
report: () => request<ConsistencyReport>("/api/consistency"),
|
|
ignore: (key: string, reason?: string) =>
|
|
request<{ ignore: ConsistencyIgnore }>("/api/consistency/ignore", { method: "POST", body: JSON.stringify({ key, reason }) }),
|
|
unignore: (id: number) => request<void>(`/api/consistency/ignore/${id}`, { method: "DELETE" }),
|
|
setExcludedRanges: (ranges: string[]) =>
|
|
request<{ excludedRanges: string[] }>("/api/consistency/excluded-ranges", { method: "PUT", body: JSON.stringify({ ranges }) }),
|
|
},
|
|
domains: {
|
|
list: () => request<DomainList>("/api/domains"),
|
|
add: (name: string) =>
|
|
request<{ domain: DomainRecord; resolvedFrom: string | null }>("/api/domains", { method: "POST", body: JSON.stringify({ name }) }),
|
|
refresh: () => request<DomainList>("/api/domains/refresh", { method: "POST" }),
|
|
check: (id: number) => request<{ domain: DomainRecord }>(`/api/domains/${id}/check`, { method: "POST" }),
|
|
remove: (id: number) => request<void>(`/api/domains/${id}`, { method: "DELETE" }),
|
|
},
|
|
tasks: {
|
|
list: (
|
|
params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {},
|
|
) => {
|
|
const qs = new URLSearchParams();
|
|
if (params.serverId) qs.set("serverId", String(params.serverId));
|
|
if (params.scheduleType) qs.set("scheduleType", params.scheduleType);
|
|
if (params.search) qs.set("search", params.search);
|
|
if (params.includeStale) qs.set("includeStale", "true");
|
|
const query = qs.toString();
|
|
return request<{ tasks: TaskRecord[] }>(`/api/tasks${query ? `?${query}` : ""}`);
|
|
},
|
|
create: (data: ManualTaskInput) =>
|
|
request<{ task: TaskRecord }>("/api/tasks", { method: "POST", body: JSON.stringify(data) }),
|
|
update: (id: number, data: Partial<Omit<ManualTaskInput, "serverId">>) =>
|
|
request<{ task: TaskRecord }>(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
|
remove: (id: number) => request<void>(`/api/tasks/${id}`, { method: "DELETE" }),
|
|
},
|
|
integrations: {
|
|
fields: () => request<{ fields: Partial<Record<IntegrationType, IntegrationField[]>> }>("/api/integrations/fields"),
|
|
list: () => request<{ integrations: IntegrationSummary[] }>("/api/integrations"),
|
|
create: (data: { type: IntegrationType; name: string; config: Record<string, string | boolean> }) =>
|
|
request<{ integration: IntegrationSummary }>("/api/integrations", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
update: (id: number, data: { name?: string; enabled?: boolean; config?: Record<string, string | boolean> }) =>
|
|
request<{ integration: IntegrationSummary }>(`/api/integrations/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
remove: (id: number) => request<void>(`/api/integrations/${id}`, { method: "DELETE" }),
|
|
test: (data: { type: IntegrationType; config: Record<string, string | boolean> }) =>
|
|
request<{ ok: boolean; latencyMs?: number; error?: string }>("/api/integrations/test", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getConfig: (id: number) =>
|
|
request<{
|
|
integration: { id: number; type: IntegrationType; name: string; enabled: boolean };
|
|
config: Record<string, string | boolean>;
|
|
}>(`/api/integrations/${id}/config`),
|
|
testExisting: (id: number, config: Record<string, string | boolean>) =>
|
|
request<{ ok: boolean; latencyMs?: number; error?: string }>(`/api/integrations/${id}/test`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ config }),
|
|
}),
|
|
tailscale: {
|
|
devices: (integrationId: number) =>
|
|
request<TailscaleDevicesResponse>(`/api/integrations/${integrationId}/tailscale/devices`),
|
|
setAuthorized: (integrationId: number, deviceId: string, authorized: boolean) =>
|
|
request<void>(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}/authorize`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ authorized }),
|
|
}),
|
|
remove: (integrationId: number, deviceId: string) =>
|
|
request<void>(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
},
|
|
gitea: {
|
|
repos: (integrationId: number) => request<{ repos: GiteaRepo[] }>(`/api/integrations/${integrationId}/gitea/repos`),
|
|
rerunFailed: (integrationId: number, owner: string, repo: string, runId: number) =>
|
|
request<void>(
|
|
`/api/integrations/${integrationId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/runs/${runId}/rerun-failed`,
|
|
{ method: "POST" },
|
|
),
|
|
},
|
|
dockhand: {
|
|
containers: (integrationId: number) =>
|
|
request<DockhandContainersResponse>(`/api/integrations/${integrationId}/dockhand/containers`),
|
|
start: (integrationId: number, envId: number, containerId: string) =>
|
|
request<void>(
|
|
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/start`,
|
|
{ method: "POST" },
|
|
),
|
|
stop: (integrationId: number, envId: number, containerId: string) =>
|
|
request<void>(
|
|
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/stop`,
|
|
{ method: "POST" },
|
|
),
|
|
restart: (integrationId: number, envId: number, containerId: string) =>
|
|
request<void>(
|
|
`/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/restart`,
|
|
{ method: "POST" },
|
|
),
|
|
checkForUpdates: (integrationId: number) =>
|
|
request<{ total: number; updatesFound: number }>(`/api/integrations/${integrationId}/dockhand/check-updates`, {
|
|
method: "POST",
|
|
}),
|
|
},
|
|
semaphore: {
|
|
templates: (integrationId: number) =>
|
|
request<SemaphoreTemplatesResponse>(`/api/integrations/${integrationId}/semaphore/templates`),
|
|
run: (integrationId: number, projectId: number, templateId: number) =>
|
|
request<{ task: SemaphoreTask }>(
|
|
`/api/integrations/${integrationId}/semaphore/projects/${projectId}/templates/${templateId}/run`,
|
|
{ method: "POST" },
|
|
),
|
|
},
|
|
proxmox: {
|
|
guests: (integrationId: number) => request<ProxmoxGuestsResponse>(`/api/integrations/${integrationId}/proxmox/guests`),
|
|
nodes: (integrationId: number) => request<ProxmoxNodesResponse>(`/api/integrations/${integrationId}/proxmox/nodes`),
|
|
backups: (integrationId: number) => request<ProxmoxBackupsResponse>(`/api/integrations/${integrationId}/proxmox/backups`),
|
|
start: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
|
|
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/start`, { method: "POST" }),
|
|
stop: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
|
|
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/stop`, { method: "POST" }),
|
|
restart: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
|
|
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/restart`, { method: "POST" }),
|
|
shutdown: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
|
|
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/shutdown`, { method: "POST" }),
|
|
},
|
|
synology: {
|
|
storage: (integrationId: number) => request<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`),
|
|
system: (integrationId: number) => request<SynologySystemInfo>(`/api/integrations/${integrationId}/synology/system`),
|
|
},
|
|
},
|
|
settings: {
|
|
get: () => request<{ settings: AppSettings }>("/api/settings"),
|
|
providerColors: () => request<{ providerColors: Record<string, string> }>("/api/settings/provider-colors"),
|
|
integrationColors: () => request<{ integrationColors: Record<string, string> }>("/api/settings/integration-colors"),
|
|
display: () => request<{ display: DisplaySettings }>("/api/settings/display"),
|
|
update: (data: AppSettingsPatch) =>
|
|
request<{ settings: AppSettings }>("/api/settings", { method: "PUT", body: JSON.stringify(data) }),
|
|
testGotify: (data: { url: string; token: string; priority?: number }) =>
|
|
request<{ ok: true }>("/api/settings/test-gotify", { method: "POST", body: JSON.stringify(data) }),
|
|
testNtfy: (data: { url: string; topic: string; token?: string; priority?: number }) =>
|
|
request<{ ok: true }>("/api/settings/test-ntfy", { method: "POST", body: JSON.stringify(data) }),
|
|
testSmtp: (data: { host: string; port?: number; secure?: boolean; username?: string; password?: string; from: string; to: string }) =>
|
|
request<{ ok: true }>("/api/settings/test-smtp", { method: "POST", body: JSON.stringify(data) }),
|
|
testWebhook: (data: { url: string; secret?: string }) =>
|
|
request<{ ok: true }>("/api/settings/test-webhook", { method: "POST", body: JSON.stringify(data) }),
|
|
purgeLogs: () =>
|
|
request<{ diagDeleted: number; auditDeleted: number }>("/api/settings/purge-logs", { method: "POST" }),
|
|
exportConfig: (passphrase: string) =>
|
|
request<EncryptedExportFile>("/api/settings/export", { method: "POST", body: JSON.stringify({ passphrase }) }),
|
|
importConfig: (passphrase: string, file: EncryptedExportFile) =>
|
|
request<ImportResult>("/api/settings/import", { method: "POST", body: JSON.stringify({ passphrase, file }) }),
|
|
quietHoursQueue: () =>
|
|
request<{ count: number; items: { id: number; title: string; message: string; createdAt: string }[] }>(
|
|
"/api/settings/quiet-hours-queue",
|
|
),
|
|
flushQuietHours: () => request<{ flushed: number }>("/api/settings/flush-quiet-hours", { method: "POST" }),
|
|
},
|
|
};
|