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:
1 parent
9c07718d2d
commit
1cae35a59e
7 files changed
+107
-3
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user