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 { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||||
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||||
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
||||||
|
import { initDockerUpdateScheduler } from "./services/dockerUpdateScheduler.js";
|
||||||
|
|
||||||
warnIfAuthNotConfigured();
|
warnIfAuthNotConfigured();
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
await initSecretExpiryScheduler();
|
await initSecretExpiryScheduler();
|
||||||
await initTailscaleKeyExpiryScheduler();
|
await initTailscaleKeyExpiryScheduler();
|
||||||
await initLogRetentionScheduler();
|
await initLogRetentionScheduler();
|
||||||
|
await initDockerUpdateScheduler();
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const webDist = join(__dirname, "..", "..", "web", "dist");
|
const webDist = join(__dirname, "..", "..", "web", "dist");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { recordAudit } from "../services/audit.js";
|
|||||||
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
||||||
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
||||||
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
|
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
|
||||||
|
import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js";
|
||||||
import { scheduleLogRetentionPurge } from "../services/logRetentionScheduler.js";
|
import { scheduleLogRetentionPurge } from "../services/logRetentionScheduler.js";
|
||||||
import { purgeOldLogs } from "../services/logRetention.js";
|
import { purgeOldLogs } from "../services/logRetention.js";
|
||||||
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
|
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
|
||||||
@@ -62,6 +63,7 @@ const updateSchema = z.object({
|
|||||||
dnsDelete: z.boolean(),
|
dnsDelete: z.boolean(),
|
||||||
secretCheck: z.boolean(),
|
secretCheck: z.boolean(),
|
||||||
tailscaleKeyCheck: z.boolean(),
|
tailscaleKeyCheck: z.boolean(),
|
||||||
|
dockerUpdateCheck: z.boolean(),
|
||||||
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
|
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
timezone: z.string(),
|
timezone: z.string(),
|
||||||
integrationFailureAlerts: z.boolean(),
|
integrationFailureAlerts: z.boolean(),
|
||||||
@@ -92,6 +94,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
|||||||
if (parsed.data.notifications) {
|
if (parsed.data.notifications) {
|
||||||
await scheduleSecretExpiryCheck();
|
await scheduleSecretExpiryCheck();
|
||||||
await scheduleTailscaleKeyExpiryCheck();
|
await scheduleTailscaleKeyExpiryCheck();
|
||||||
|
await scheduleDockerUpdateCheck();
|
||||||
}
|
}
|
||||||
if (parsed.data.logRetention) {
|
if (parsed.data.logRetention) {
|
||||||
await scheduleLogRetentionPurge();
|
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(
|
export async function notifyTailscaleKeyExpiry(
|
||||||
expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[],
|
expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export interface NotificationEvents {
|
|||||||
dnsDelete: boolean;
|
dnsDelete: boolean;
|
||||||
secretCheck: boolean;
|
secretCheck: boolean;
|
||||||
tailscaleKeyCheck: 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;
|
timezone: string;
|
||||||
integrationFailureAlerts: boolean;
|
integrationFailureAlerts: boolean;
|
||||||
/** Consecutive failed calls (from the Diagnostic Log) before an integration/DNS provider is considered down. */
|
/** Consecutive failed calls (from the Diagnostic Log) before an integration/DNS provider is considered down. */
|
||||||
@@ -91,6 +92,7 @@ const DEFAULTS: AppSettings = {
|
|||||||
dnsDelete: true,
|
dnsDelete: true,
|
||||||
secretCheck: true,
|
secretCheck: true,
|
||||||
tailscaleKeyCheck: true,
|
tailscaleKeyCheck: true,
|
||||||
|
dockerUpdateCheck: true,
|
||||||
secretCheckTime: "08:00",
|
secretCheckTime: "08:00",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
integrationFailureAlerts: true,
|
integrationFailureAlerts: true,
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export interface NotificationEvents {
|
|||||||
dnsDelete: boolean;
|
dnsDelete: boolean;
|
||||||
secretCheck: boolean;
|
secretCheck: boolean;
|
||||||
tailscaleKeyCheck: boolean;
|
tailscaleKeyCheck: boolean;
|
||||||
|
dockerUpdateCheck: boolean;
|
||||||
secretCheckTime: string;
|
secretCheckTime: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
integrationFailureAlerts: boolean;
|
integrationFailureAlerts: boolean;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = {
|
|||||||
dnsDelete: true,
|
dnsDelete: true,
|
||||||
secretCheck: true,
|
secretCheck: true,
|
||||||
tailscaleKeyCheck: true,
|
tailscaleKeyCheck: true,
|
||||||
|
dockerUpdateCheck: true,
|
||||||
secretCheckTime: "08:00",
|
secretCheckTime: "08:00",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
integrationFailureAlerts: true,
|
integrationFailureAlerts: true,
|
||||||
@@ -491,6 +492,7 @@ export default function NotificationSettings() {
|
|||||||
{ key: "dnsDelete" as const, label: "DNS record deleted" },
|
{ key: "dnsDelete" as const, label: "DNS record deleted" },
|
||||||
{ key: "secretCheck" as const, label: "Secret expiry reminder" },
|
{ key: "secretCheck" as const, label: "Secret expiry reminder" },
|
||||||
{ key: "tailscaleKeyCheck" as const, label: "Tailscale key 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" },
|
{ key: "integrationFailureAlerts" as const, label: "Integration/DNS provider failing repeatedly" },
|
||||||
].map(({ key, label }) => (
|
].map(({ key, label }) => (
|
||||||
<label key={key} className="form-check mb-2">
|
<label key={key} className="form-check mb-2">
|
||||||
@@ -524,7 +526,7 @@ export default function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const dailyChecksEnabled = notifications.secretCheck || notifications.tailscaleKeyCheck;
|
const dailyChecksEnabled = notifications.secretCheck || notifications.tailscaleKeyCheck || notifications.dockerUpdateCheck;
|
||||||
return (
|
return (
|
||||||
<div className="row g-2 mt-2">
|
<div className="row g-2 mt-2">
|
||||||
<div className="col-6">
|
<div className="col-6">
|
||||||
@@ -536,7 +538,7 @@ export default function NotificationSettings() {
|
|||||||
disabled={!dailyChecksEnabled}
|
disabled={!dailyChecksEnabled}
|
||||||
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
|
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>
|
||||||
<div className="col-6">
|
<div className="col-6">
|
||||||
<label className="form-label">Timezone</label>
|
<label className="form-label">Timezone</label>
|
||||||
|
|||||||
Reference in new issue
Block a user