diff --git a/server/src/index.ts b/server/src/index.ts index 86d8611..76ae8db 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,12 +25,14 @@ import { searchRouter } from "./routes/search.js"; import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; +import { initDockerUpdateScheduler } from "./services/dockerUpdateScheduler.js"; warnIfAuthNotConfigured(); await runMigrations(); await initSecretExpiryScheduler(); await initTailscaleKeyExpiryScheduler(); await initLogRetentionScheduler(); +await initDockerUpdateScheduler(); const __dirname = dirname(fileURLToPath(import.meta.url)); const webDist = join(__dirname, "..", "..", "web", "dist"); diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 76adc02..2bc6675 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -5,6 +5,7 @@ import { recordAudit } from "../services/audit.js"; import { getSettings, updateSettings } from "../services/settingsStore.js"; import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js"; import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js"; +import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js"; import { scheduleLogRetentionPurge } from "../services/logRetentionScheduler.js"; import { purgeOldLogs } from "../services/logRetention.js"; import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js"; @@ -62,6 +63,7 @@ const updateSchema = z.object({ dnsDelete: z.boolean(), secretCheck: z.boolean(), tailscaleKeyCheck: z.boolean(), + dockerUpdateCheck: z.boolean(), secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/), timezone: z.string(), integrationFailureAlerts: z.boolean(), @@ -92,6 +94,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => { if (parsed.data.notifications) { await scheduleSecretExpiryCheck(); await scheduleTailscaleKeyExpiryCheck(); + await scheduleDockerUpdateCheck(); } if (parsed.data.logRetention) { await scheduleLogRetentionPurge(); diff --git a/server/src/services/dockerUpdateScheduler.ts b/server/src/services/dockerUpdateScheduler.ts new file mode 100644 index 0000000..91b5bda --- /dev/null +++ b/server/src/services/dockerUpdateScheduler.ts @@ -0,0 +1,80 @@ +import schedule from "node-schedule"; +import { and, eq } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { integrations } from "../db/schema.js"; +import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; +import { createDockhandAdapter } from "../integrations/dockhand/adapter.js"; +import { notifyDockerUpdates } from "./notify.js"; +import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js"; + +const LAST_RUN_FLAG = "dockerUpdateCheckLastRunDate"; + +async function checkDockerUpdates(): Promise { + const rows = await db + .select({ id: integrations.id, name: integrations.name }) + .from(integrations) + .where(and(eq(integrations.type, "dockhand"), eq(integrations.enabled, true))); + + const updatesAvailable: { integrationName: string; containerName: string; environmentName: string; newerVersion: string | null }[] = []; + + for (const row of rows) { + try { + const loaded = await loadIntegrationConfig(row.id); + if (!loaded) continue; + const adapter = createDockhandAdapter(loaded.config as any); + // Reads Dockhand's cached update-check results — doesn't trigger a fresh + // per-container registry lookup, same data the Docker page itself shows. + const containers = await adapter.listContainers(); + for (const c of containers) { + if (!c.updateAvailable) continue; + updatesAvailable.push({ + integrationName: row.name, + containerName: c.name, + environmentName: c.environmentName, + newerVersion: c.newerVersion, + }); + } + } catch (err) { + console.error(`[dockerUpdate] check failed for integration ${row.id}:`, err); + } + } + + await notifyDockerUpdates(updatesAvailable); +} + +async function checkDockerUpdatesOnce(): Promise { + const today = new Date().toDateString(); + const lastRun = await getInternalFlag(LAST_RUN_FLAG); + if (lastRun === today) return; + await setInternalFlag(LAST_RUN_FLAG, today); + await checkDockerUpdates(); +} + +function cronFromTime(time: string): string { + const [h, m] = time.split(":").map(Number); + return `${Number.isFinite(m) ? m : 0} ${Number.isFinite(h) ? h : 8} * * *`; +} + +let currentJob: schedule.Job | null = null; + +/** (Re)schedules the daily Docker image-update check per the current notification settings. Call again after settings change. */ +export async function scheduleDockerUpdateCheck(): Promise { + if (currentJob) { + currentJob.cancel(); + currentJob = null; + } + const { notifications } = await getSettings(); + currentJob = schedule.scheduleJob({ rule: cronFromTime(notifications.secretCheckTime), tz: notifications.timezone }, () => { + setInternalFlag(LAST_RUN_FLAG, "").catch(() => {}); + getSettings().then(({ notifications: n }) => { + if (n.dockerUpdateCheck) checkDockerUpdates().catch((err) => console.error("[dockerUpdate] check failed:", err)); + }); + }); + console.log(`Docker update check scheduled at ${notifications.secretCheckTime} (${notifications.timezone})`); +} + +/** Runs once at startup (skipped if already run today), then arms the daily schedule. */ +export async function initDockerUpdateScheduler(): Promise { + await checkDockerUpdatesOnce(); + await scheduleDockerUpdateCheck(); +} diff --git a/server/src/services/notify.ts b/server/src/services/notify.ts index 66fdf02..34ffc18 100644 --- a/server/src/services/notify.ts +++ b/server/src/services/notify.ts @@ -178,6 +178,20 @@ export async function notifySecretExpiry( ); } +export async function notifyDockerUpdates( + updatesAvailable: { integrationName: string; containerName: string; environmentName: string; newerVersion: string | null }[], +): Promise { + if (updatesAvailable.length === 0) return; + if (!(await eventEnabled("dockerUpdateCheck"))) return; + const lines = updatesAvailable.map( + (u) => `${u.containerName} [${u.environmentName}, ${u.integrationName}]${u.newerVersion ? ` → ${u.newerVersion}` : ""}`, + ); + await notify( + "Homelab Manager — Docker Updates Available", + `${updatesAvailable.length} container${updatesAvailable.length !== 1 ? "s have" : " has"} an image update available:\n\n${lines.join("\n")}`, + ); +} + export async function notifyTailscaleKeyExpiry( expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[], ): Promise { diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index fc325ad..e2ca3a4 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -40,7 +40,8 @@ export interface NotificationEvents { dnsDelete: boolean; secretCheck: boolean; tailscaleKeyCheck: boolean; - secretCheckTime: string; // "HH:MM" — shared by the secret-expiry and Tailscale key-expiry checks + dockerUpdateCheck: boolean; + secretCheckTime: string; // "HH:MM" — shared by the secret-expiry, Tailscale key-expiry, and Docker update checks timezone: string; integrationFailureAlerts: boolean; /** Consecutive failed calls (from the Diagnostic Log) before an integration/DNS provider is considered down. */ @@ -91,6 +92,7 @@ const DEFAULTS: AppSettings = { dnsDelete: true, secretCheck: true, tailscaleKeyCheck: true, + dockerUpdateCheck: true, secretCheckTime: "08:00", timezone: "UTC", integrationFailureAlerts: true, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a5cf27d..2d799fc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -140,6 +140,7 @@ export interface NotificationEvents { dnsDelete: boolean; secretCheck: boolean; tailscaleKeyCheck: boolean; + dockerUpdateCheck: boolean; secretCheckTime: string; timezone: string; integrationFailureAlerts: boolean; diff --git a/web/src/pages/settings/NotificationSettings.tsx b/web/src/pages/settings/NotificationSettings.tsx index 161c3ae..f7abb82 100644 --- a/web/src/pages/settings/NotificationSettings.tsx +++ b/web/src/pages/settings/NotificationSettings.tsx @@ -42,6 +42,7 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = { dnsDelete: true, secretCheck: true, tailscaleKeyCheck: true, + dockerUpdateCheck: true, secretCheckTime: "08:00", timezone: "UTC", integrationFailureAlerts: true, @@ -491,6 +492,7 @@ export default function NotificationSettings() { { key: "dnsDelete" as const, label: "DNS record deleted" }, { key: "secretCheck" as const, label: "Secret expiry reminder" }, { key: "tailscaleKeyCheck" as const, label: "Tailscale key expiry reminder" }, + { key: "dockerUpdateCheck" as const, label: "Docker image update available" }, { key: "integrationFailureAlerts" as const, label: "Integration/DNS provider failing repeatedly" }, ].map(({ key, label }) => (