Add a daily Docker image-update notification

Dockhand's pending-update counts were only visible if you happened to
open the Docker page. New "Docker image update available" toggle
under Settings -> Notifications, sharing the same daily
time/timezone as the secret and Tailscale key expiry reminders (same
node-schedule reschedule-on-settings-change pattern as those two).
Reads each enabled Dockhand integration's already-cached update-check
results via listContainers() rather than triggering a fresh
per-container registry lookup, so it costs nothing extra beyond what
the Docker page itself already fetches, and lists every container
with an update pending across all environments/integrations in one
notification.

Verified end-to-end against a fake local Dockhand server (one
environment, two containers, one flagged with a pending update) and a
fake webhook receiver on an isolated scratch database: the check
correctly found only the flagged container (with its newerVersion),
sent exactly one notification with the right title/content, excluded
the up-to-date container, and sent nothing at all when the setting
was toggled off despite still finding the same pending update.
Confirmed the real dev database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 23:44:47 +02:00
co-authored by Claude Sonnet 5
parent 9c07718d2d
commit 1cae35a59e
7 changed files with 107 additions and 3 deletions
+2
View File
@@ -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");
+3
View File
@@ -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();
@@ -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<void> {
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<void> {
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<void> {
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<void> {
await checkDockerUpdatesOnce();
await scheduleDockerUpdateCheck();
}
+14
View File
@@ -178,6 +178,20 @@ export async function notifySecretExpiry(
);
}
export async function notifyDockerUpdates(
updatesAvailable: { integrationName: string; containerName: string; environmentName: string; newerVersion: string | null }[],
): Promise<void> {
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<void> {
+3 -1
View File
@@ -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,
+1
View File
@@ -140,6 +140,7 @@ export interface NotificationEvents {
dnsDelete: boolean;
secretCheck: boolean;
tailscaleKeyCheck: boolean;
dockerUpdateCheck: boolean;
secretCheckTime: string;
timezone: string;
integrationFailureAlerts: boolean;
@@ -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 }) => (
<label key={key} className="form-check mb-2">
@@ -524,7 +526,7 @@ export default function NotificationSettings() {
</div>
</div>
{(() => {
const dailyChecksEnabled = notifications.secretCheck || notifications.tailscaleKeyCheck;
const dailyChecksEnabled = notifications.secretCheck || notifications.tailscaleKeyCheck || notifications.dockerUpdateCheck;
return (
<div className="row g-2 mt-2">
<div className="col-6">
@@ -536,7 +538,7 @@ export default function NotificationSettings() {
disabled={!dailyChecksEnabled}
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
/>
<div className="form-hint">Shared by the secret and Tailscale key expiry reminders above.</div>
<div className="form-hint">Shared by the secret, Tailscale key, and Docker update reminders above.</div>
</div>
<div className="col-6">
<label className="form-label">Timezone</label>