mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.1.8 — in-app update notifications
- Header indicator (pulsating dot + "NEW VERSION") when a newer release is published on GitHub. Replaces the version subtitle without growing the header. - Modal with current → latest version, release notes preview (parsed from GitHub release body), copy-paste update command, link to repo and release notes, current star count, and "versions behind" badge. - Auto-detects deploy mode (GHCR image vs source build) by inspecting the running container. Shows the matching update command, but always exposes both via tabs. - Backend caches the GitHub API response for 6h (~8 calls/day per instance, far under the rate limit).
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "containerflow",
|
"name": "containerflow",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"author": "Jorge Gonzalez D. (RGJorge)",
|
"author": "Jorge Gonzalez D. (RGJorge)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types";
|
import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
|
import { useUpdateInfo } from "../hooks/useUpdateInfo";
|
||||||
|
import { UpdateModal } from "./UpdateModal";
|
||||||
|
|
||||||
export type Page = "dashboard" | "monitoring" | "settings";
|
export type Page = "dashboard" | "monitoring" | "settings";
|
||||||
|
|
||||||
@@ -191,6 +193,8 @@ export function HeaderBar({
|
|||||||
onOpenServiceDetail,
|
onOpenServiceDetail,
|
||||||
}: HeaderBarProps) {
|
}: HeaderBarProps) {
|
||||||
const { t, lang, setLang } = useT();
|
const { t, lang, setLang } = useT();
|
||||||
|
const { info: updateInfo, showIndicator } = useUpdateInfo(token);
|
||||||
|
const [updateOpen, setUpdateOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-5 py-1 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
|
<div className="flex items-center justify-between px-5 py-1 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
|
||||||
@@ -201,7 +205,8 @@ export function HeaderBar({
|
|||||||
<NavButton icon={Settings} label={t("header.settings")} active={activePage === "settings"} onClick={() => onPageChange("settings")} />
|
<NavButton icon={Settings} label={t("header.settings")} active={activePage === "settings"} onClick={() => onPageChange("settings")} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Center: Logo */}
|
{/* Center: Logo. The "subtitle" line shows the current version normally,
|
||||||
|
but is replaced by the "update available" badge when an update ships. */}
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-2.5">
|
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-2.5">
|
||||||
<img
|
<img
|
||||||
src="/alteonx-logo.webp"
|
src="/alteonx-logo.webp"
|
||||||
@@ -211,7 +216,21 @@ export function HeaderBar({
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="text-base font-bold text-white tracking-wide">ContainerFlow</span>
|
<span className="text-base font-bold text-white tracking-wide">ContainerFlow</span>
|
||||||
|
{showIndicator && updateInfo?.latest ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setUpdateOpen(true)}
|
||||||
|
className="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-wider text-emerald-400 hover:text-emerald-300 transition-colors whitespace-nowrap -mt-1 animate-pulse hover:animate-none"
|
||||||
|
title={`v${updateInfo.current} → v${updateInfo.latest}`}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-400" />
|
||||||
|
</span>
|
||||||
|
{t("update.available")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<span className="text-[9px] text-slate-500 font-mono -mt-1">v{__APP_VERSION__}</span>
|
<span className="text-[9px] text-slate-500 font-mono -mt-1">v{__APP_VERSION__}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,6 +286,13 @@ export function HeaderBar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{updateOpen && updateInfo && (
|
||||||
|
<UpdateModal
|
||||||
|
info={updateInfo}
|
||||||
|
onClose={() => setUpdateOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { X, ExternalLink, Copy, Check, Container, GitBranch, Github, Star } from "lucide-react";
|
||||||
|
import type { UpdateInfo, DeployMode } from "../../shared/types";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
|
interface UpdateModalProps {
|
||||||
|
info: UpdateInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabKey = Exclude<DeployMode, "unknown">;
|
||||||
|
|
||||||
|
const COMMANDS: Record<TabKey, string> = {
|
||||||
|
ghcr: "docker compose pull && docker compose up -d",
|
||||||
|
source: "git pull && docker compose up -d --build",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpdateModal({ info, onClose }: UpdateModalProps) {
|
||||||
|
const { t } = useT();
|
||||||
|
// Default tab: detected mode if it's ghcr or source, otherwise ghcr.
|
||||||
|
const initialTab: TabKey = info.deployMode === "source" ? "source" : "ghcr";
|
||||||
|
const [tab, setTab] = useState<TabKey>(initialTab);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Lock body scroll + block wheel events on canvas (React Flow zooms on wheel)
|
||||||
|
// while the modal is open. Restore on close.
|
||||||
|
useEffect(() => {
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
const blockWheel = (e: WheelEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest("[data-update-modal]")) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("wheel", blockWheel, { passive: false, capture: true });
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
document.removeEventListener("wheel", blockWheel, { capture: true } as any);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
const text = COMMANDS[tab];
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (!ok) {
|
||||||
|
// Fallback for non-secure contexts (HTTP from LAN IP, older browsers).
|
||||||
|
try {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.left = "-9999px";
|
||||||
|
ta.style.top = "0";
|
||||||
|
ta.setAttribute("readonly", "");
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
ta.setSelectionRange(0, text.length);
|
||||||
|
ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} else {
|
||||||
|
console.warn("ContainerFlow: clipboard copy failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseLines = info.releaseNotes ? info.releaseNotes.split("\n").slice(0, 15) : [];
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100000] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-update-modal
|
||||||
|
className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header — 3-row grid so version aligns with "X versions behind"
|
||||||
|
badge, and release-notes link aligns with the repo link. */}
|
||||||
|
<div className="relative grid grid-cols-[1fr_auto] gap-x-4 gap-y-1.5 px-5 pt-5 pb-3 border-b border-slate-800 items-center">
|
||||||
|
{/* Close button — absolutely positioned top-right so it doesn't
|
||||||
|
affect grid row heights. */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Row 1: label / (empty, X lives absolute) */}
|
||||||
|
<div className="text-xs uppercase tracking-wider text-emerald-400 font-semibold">
|
||||||
|
{t("update.available")}
|
||||||
|
</div>
|
||||||
|
<div className="w-5" /> {/* spacer matching X width */}
|
||||||
|
|
||||||
|
{/* Row 2: version / releases-behind badge */}
|
||||||
|
<div className="text-lg font-bold tracking-tight">
|
||||||
|
<span className="text-slate-300">v{info.current}</span>{" "}
|
||||||
|
<span className="text-slate-500">→</span>{" "}
|
||||||
|
<span className="text-emerald-400">v{info.latest}</span>
|
||||||
|
</div>
|
||||||
|
{info.releasesAhead > 1 ? (
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-500/15 border border-amber-500/30 text-amber-300 whitespace-nowrap justify-self-end">
|
||||||
|
{t("update.releasesBehind").replace("{n}", String(info.releasesAhead))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 3: release notes link / repo link */}
|
||||||
|
{info.releaseUrl ? (
|
||||||
|
<a
|
||||||
|
href={info.releaseUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-cyan-400 hover:text-cyan-300 transition-colors w-fit"
|
||||||
|
>
|
||||||
|
{t("update.fullNotes")}
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={info.repoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group inline-flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors whitespace-nowrap justify-self-end"
|
||||||
|
>
|
||||||
|
<Github size={12} />
|
||||||
|
{t("update.viewRepo")}
|
||||||
|
{info.stars !== null && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
· {info.stars.toLocaleString()}
|
||||||
|
<Star size={10} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Release notes preview — caps height + scrolls when there are many changes */}
|
||||||
|
{releaseLines.length > 0 && (
|
||||||
|
<div className="px-5 py-4 border-b border-slate-800">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-slate-500 font-semibold mb-2">
|
||||||
|
{t("update.whatsNew")}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 max-h-40 overflow-y-auto pr-1">
|
||||||
|
{releaseLines.map((line, i) => (
|
||||||
|
<li key={i} className="text-sm text-slate-300 leading-relaxed flex gap-2">
|
||||||
|
<span className="text-slate-600 shrink-0">•</span>
|
||||||
|
<span>{line}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* How to update — tabs */}
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-slate-500 font-semibold mb-2">
|
||||||
|
{t("update.howToUpdate")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab buttons */}
|
||||||
|
<div className="flex items-center gap-1 mb-3 bg-slate-800/50 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("ghcr")}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
tab === "ghcr"
|
||||||
|
? "bg-slate-700 text-white"
|
||||||
|
: "text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Container size={13} />
|
||||||
|
{t("update.tabGhcr")}
|
||||||
|
{info.deployMode === "ghcr" && (
|
||||||
|
<span className="text-[9px] text-emerald-400 ml-0.5">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("source")}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
tab === "source"
|
||||||
|
? "bg-slate-700 text-white"
|
||||||
|
: "text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitBranch size={13} />
|
||||||
|
{t("update.tabSource")}
|
||||||
|
{info.deployMode === "source" && (
|
||||||
|
<span className="text-[9px] text-emerald-400 ml-0.5">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab description */}
|
||||||
|
<div className="text-[11px] text-slate-500 mb-2">
|
||||||
|
{tab === "ghcr" ? t("update.ghcrHint") : t("update.sourceHint")}
|
||||||
|
{info.deployMode === tab && (
|
||||||
|
<span className="text-emerald-400 ml-1.5">· {t("update.detectedMode")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Command block */}
|
||||||
|
<div className="relative bg-slate-950 border border-slate-800 rounded-md p-3 pr-12 font-mono text-[11px] text-slate-200 overflow-x-auto">
|
||||||
|
<code className="whitespace-pre">{COMMANDS[tab]}</code>
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
className="absolute top-2 right-2 p-1.5 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
||||||
|
title={copied ? t("update.copied") : t("update.copyCommand")}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={13} className="text-emerald-400" /> : <Copy size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { UpdateInfo } from "../../shared/types";
|
||||||
|
|
||||||
|
/** Hook for in-app update notification.
|
||||||
|
* - Fetches /api/update-info on mount + on window focus (server caches 6h)
|
||||||
|
* - Indicator persists while `updateAvailable` is true — no dismiss option
|
||||||
|
* by design, so users actually update instead of silencing the prompt.
|
||||||
|
*/
|
||||||
|
export function useUpdateInfo(token: string) {
|
||||||
|
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/update-info", { headers });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as UpdateInfo;
|
||||||
|
setInfo(data);
|
||||||
|
} catch {
|
||||||
|
// Network errors are silent — no notification shown
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
const onFocus = () => refetch();
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
return () => window.removeEventListener("focus", onFocus);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
const showIndicator = Boolean(info?.updateAvailable && info.latest);
|
||||||
|
|
||||||
|
return { info, showIndicator, refetch };
|
||||||
|
}
|
||||||
@@ -34,6 +34,21 @@ const en = {
|
|||||||
"group.changeColor": "Change color",
|
"group.changeColor": "Change color",
|
||||||
"group.resetColor": "Reset color",
|
"group.resetColor": "Reset color",
|
||||||
|
|
||||||
|
// Update notification
|
||||||
|
"update.available": "Update available",
|
||||||
|
"update.whatsNew": "What's new",
|
||||||
|
"update.howToUpdate": "How to update",
|
||||||
|
"update.tabGhcr": "Prebuilt image",
|
||||||
|
"update.tabSource": "Local build",
|
||||||
|
"update.ghcrHint": "If you pulled the image from GitHub Container Registry.",
|
||||||
|
"update.sourceHint": "If you cloned the repo and build from source.",
|
||||||
|
"update.detectedMode": "Detected",
|
||||||
|
"update.copyCommand": "Copy command",
|
||||||
|
"update.copied": "Copied",
|
||||||
|
"update.fullNotes": "See full release notes",
|
||||||
|
"update.viewRepo": "View on GitHub",
|
||||||
|
"update.releasesBehind": "{n} versions behind",
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
"login.connecting": "Connecting...",
|
"login.connecting": "Connecting...",
|
||||||
"login.connect": "Connect",
|
"login.connect": "Connect",
|
||||||
@@ -305,6 +320,21 @@ const es: Record<TranslationKey, string> = {
|
|||||||
"group.saveAlias": "Guardar",
|
"group.saveAlias": "Guardar",
|
||||||
"group.changeColor": "Cambiar color",
|
"group.changeColor": "Cambiar color",
|
||||||
"group.resetColor": "Restaurar color",
|
"group.resetColor": "Restaurar color",
|
||||||
|
|
||||||
|
// Update notification
|
||||||
|
"update.available": "Nueva versión",
|
||||||
|
"update.whatsNew": "Qué hay de nuevo",
|
||||||
|
"update.howToUpdate": "Cómo actualizar",
|
||||||
|
"update.tabGhcr": "Imagen prebuilt",
|
||||||
|
"update.tabSource": "Build local",
|
||||||
|
"update.ghcrHint": "Si descargaste la imagen desde GitHub Container Registry.",
|
||||||
|
"update.sourceHint": "Si clonaste el repo y construís desde código fuente.",
|
||||||
|
"update.detectedMode": "Detectado",
|
||||||
|
"update.copyCommand": "Copiar comando",
|
||||||
|
"update.copied": "Copiado",
|
||||||
|
"update.fullNotes": "Ver notas completas",
|
||||||
|
"update.viewRepo": "Ver repositorio",
|
||||||
|
"update.releasesBehind": "{n} versiones atrás",
|
||||||
"group.cancelAlias": "Cancelar",
|
"group.cancelAlias": "Cancelar",
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
|
|||||||
+31
-1
@@ -10,6 +10,8 @@ import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResource
|
|||||||
import { loadContainerSettings, saveContainerSettings } from "./container-settings";
|
import { loadContainerSettings, saveContainerSettings } from "./container-settings";
|
||||||
import { loadProjectAliases, saveProjectAliases, sanitizeAlias } from "./project-aliases";
|
import { loadProjectAliases, saveProjectAliases, sanitizeAlias } from "./project-aliases";
|
||||||
import { loadProjectColors, saveProjectColors, sanitizeColor } from "./project-colors";
|
import { loadProjectColors, saveProjectColors, sanitizeColor } from "./project-colors";
|
||||||
|
import { getUpdateInfo } from "./update-check";
|
||||||
|
import pkg from "../../package.json";
|
||||||
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
|
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
|
||||||
import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-db";
|
import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-db";
|
||||||
import type { Service, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
|
import type { Service, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
|
||||||
@@ -206,7 +208,13 @@ app.get("/api/init", async (c) => {
|
|||||||
// the dashboard. The first regular poll (within ~3s) populates via WS.
|
// the dashboard. The first regular poll (within ~3s) populates via WS.
|
||||||
const projectAliases = loadProjectAliases();
|
const projectAliases = loadProjectAliases();
|
||||||
const projectColors = loadProjectColors();
|
const projectColors = loadProjectColors();
|
||||||
return c.json({ services, connections, positions, stats: lastStats, projectAliases, projectColors });
|
// Update info: best-effort, never block init. If the check fails (offline,
|
||||||
|
// rate-limit), return null so the UI just doesn't show a notification.
|
||||||
|
let updateInfo = null;
|
||||||
|
try {
|
||||||
|
updateInfo = await getUpdateInfo(pkg.version);
|
||||||
|
} catch {}
|
||||||
|
return c.json({ services, connections, positions, stats: lastStats, projectAliases, projectColors, updateInfo });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
|
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
|
||||||
@@ -692,6 +700,28 @@ app.delete("/api/project-colors/:project", (c) => {
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Update info ──
|
||||||
|
app.get("/api/update-info", async (c) => {
|
||||||
|
try {
|
||||||
|
const info = await getUpdateInfo(pkg.version);
|
||||||
|
return c.json(info);
|
||||||
|
} catch {
|
||||||
|
// Silent fallback — never error the client with this metadata call.
|
||||||
|
return c.json({
|
||||||
|
current: pkg.version,
|
||||||
|
latest: null,
|
||||||
|
updateAvailable: false,
|
||||||
|
releasesAhead: 0,
|
||||||
|
releaseUrl: null,
|
||||||
|
repoUrl: "https://github.com/RGJorge/ContainerFlow",
|
||||||
|
releaseNotes: null,
|
||||||
|
publishedAt: null,
|
||||||
|
deployMode: "unknown",
|
||||||
|
stars: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Stats history ──
|
// ── Stats history ──
|
||||||
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
|
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import Docker from "dockerode";
|
||||||
|
|
||||||
|
const REPO = "RGJorge/ContainerFlow";
|
||||||
|
const REPO_URL = `https://github.com/${REPO}`;
|
||||||
|
const RELEASES_URL = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
|
||||||
|
const REPO_INFO_URL = `https://api.github.com/repos/${REPO}`;
|
||||||
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h
|
||||||
|
|
||||||
|
export type DeployMode = "ghcr" | "source" | "unknown";
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
current: string;
|
||||||
|
latest: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
/** Number of stable releases between current and latest (e.g. 7 if you're on 0.1.0 and latest is 0.1.7). */
|
||||||
|
releasesAhead: number;
|
||||||
|
releaseUrl: string | null;
|
||||||
|
repoUrl: string;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
deployMode: DeployMode;
|
||||||
|
/** Current star count on the GitHub repo (null if fetch failed). */
|
||||||
|
stars: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
data: UpdateInfo;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: CacheEntry | null = null;
|
||||||
|
let inFlight: Promise<UpdateInfo> | null = null;
|
||||||
|
|
||||||
|
function parseSemver(v: string): [number, number, number] | null {
|
||||||
|
const m = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
return [parseInt(m[1]!), parseInt(m[2]!), parseInt(m[3]!)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewer(latest: string, current: string): boolean {
|
||||||
|
const a = parseSemver(latest);
|
||||||
|
const b = parseSemver(current);
|
||||||
|
if (!a || !b) return false;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (a[i]! > b[i]!) return true;
|
||||||
|
if (a[i]! < b[i]!) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectDeployMode(): Promise<DeployMode> {
|
||||||
|
try {
|
||||||
|
const hostname = process.env.HOSTNAME;
|
||||||
|
if (!hostname) return "unknown";
|
||||||
|
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||||
|
const container = await docker.getContainer(hostname).inspect();
|
||||||
|
const image = container.Config?.Image || "";
|
||||||
|
if (image.startsWith("ghcr.io/rgjorge/containerflow")) return "ghcr";
|
||||||
|
if (image === "containerflow:local" || image.startsWith("containerflow:")) return "source";
|
||||||
|
return "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the first ~6 highlight lines from release notes. Captures both
|
||||||
|
// bullet lists and ### / ## headings so any reasonable release format works.
|
||||||
|
function summarizeReleaseNotes(body: string | undefined | null): string | null {
|
||||||
|
if (!body) return null;
|
||||||
|
const SKIP_HEADINGS = /^(what'?s new|changelog|full changelog|notes|highlights)$/i;
|
||||||
|
const lines = body.split(/\r?\n/);
|
||||||
|
const bullets: string[] = [];
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
// Bullet list items
|
||||||
|
if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||||
|
bullets.push(stripMd(line.replace(/^[-*]\s+/, "")));
|
||||||
|
}
|
||||||
|
// Numbered list items
|
||||||
|
else if (/^\d+\.\s/.test(line)) {
|
||||||
|
bullets.push(stripMd(line.replace(/^\d+\.\s+/, "")));
|
||||||
|
}
|
||||||
|
// ## or ### headings (skip the generic "What's new" wrappers)
|
||||||
|
else if (line.startsWith("### ") || line.startsWith("## ")) {
|
||||||
|
const text = stripMd(line.replace(/^#+\s+/, ""));
|
||||||
|
if (!SKIP_HEADINGS.test(text)) bullets.push(text);
|
||||||
|
}
|
||||||
|
if (bullets.length >= 15) break;
|
||||||
|
}
|
||||||
|
return bullets.length > 0 ? bullets.join("\n") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMd(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/`([^`]+)`/g, "$1") // inline code
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "$1") // bold
|
||||||
|
.replace(/\*([^*]+)\*/g, "$1") // italic
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links → text
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyInfo(currentVersion: string, deployMode: DeployMode, stars: number | null = null): UpdateInfo {
|
||||||
|
return {
|
||||||
|
current: currentVersion,
|
||||||
|
latest: null,
|
||||||
|
updateAvailable: false,
|
||||||
|
releasesAhead: 0,
|
||||||
|
releaseUrl: null,
|
||||||
|
repoUrl: REPO_URL,
|
||||||
|
releaseNotes: null,
|
||||||
|
publishedAt: null,
|
||||||
|
deployMode,
|
||||||
|
stars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStars(currentVersion: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(REPO_INFO_URL, {
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": `ContainerFlow/${currentVersion}`,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { stargazers_count?: number };
|
||||||
|
return typeof data.stargazers_count === "number" ? data.stargazers_count : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatest(currentVersion: string): Promise<UpdateInfo> {
|
||||||
|
const [deployMode, stars] = await Promise.all([detectDeployMode(), fetchStars(currentVersion)]);
|
||||||
|
try {
|
||||||
|
const res = await fetch(RELEASES_URL, {
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": `ContainerFlow/${currentVersion}`,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return emptyInfo(currentVersion, deployMode, stars);
|
||||||
|
|
||||||
|
const releases = (await res.json()) as Array<{
|
||||||
|
tag_name?: string;
|
||||||
|
html_url?: string;
|
||||||
|
body?: string;
|
||||||
|
published_at?: string;
|
||||||
|
prerelease?: boolean;
|
||||||
|
draft?: boolean;
|
||||||
|
}>;
|
||||||
|
if (!Array.isArray(releases) || releases.length === 0) {
|
||||||
|
return emptyInfo(currentVersion, deployMode, stars);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable releases only (no drafts, no prereleases). GitHub returns them
|
||||||
|
// sorted newest first, which is what we want for `latest`.
|
||||||
|
const stable = releases.filter((r) => !r.prerelease && !r.draft && r.tag_name);
|
||||||
|
if (stable.length === 0) return emptyInfo(currentVersion, deployMode, stars);
|
||||||
|
|
||||||
|
const latestRelease = stable[0]!;
|
||||||
|
const latest = latestRelease.tag_name!.replace(/^v/, "");
|
||||||
|
const updateAvailable = isNewer(latest, currentVersion);
|
||||||
|
|
||||||
|
// How many stable releases are strictly newer than what the user is running?
|
||||||
|
let releasesAhead = 0;
|
||||||
|
if (updateAvailable) {
|
||||||
|
for (const r of stable) {
|
||||||
|
const v = r.tag_name!.replace(/^v/, "");
|
||||||
|
if (isNewer(v, currentVersion)) releasesAhead++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: currentVersion,
|
||||||
|
latest,
|
||||||
|
updateAvailable,
|
||||||
|
releasesAhead,
|
||||||
|
releaseUrl: latestRelease.html_url || `${REPO_URL}/releases/tag/${latestRelease.tag_name}`,
|
||||||
|
repoUrl: REPO_URL,
|
||||||
|
releaseNotes: summarizeReleaseNotes(latestRelease.body),
|
||||||
|
publishedAt: latestRelease.published_at || null,
|
||||||
|
deployMode,
|
||||||
|
stars,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return emptyInfo(currentVersion, deployMode, stars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUpdateInfo(currentVersion: string): Promise<UpdateInfo> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) return cache.data;
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = fetchLatest(currentVersion)
|
||||||
|
.then((data) => {
|
||||||
|
cache = { data, fetchedAt: Date.now() };
|
||||||
|
inFlight = null;
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
inFlight = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
@@ -131,6 +131,21 @@ export interface ServerConfig {
|
|||||||
restrictedMode: boolean;
|
restrictedMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeployMode = "ghcr" | "source" | "unknown";
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
current: string;
|
||||||
|
latest: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
releasesAhead: number;
|
||||||
|
releaseUrl: string | null;
|
||||||
|
repoUrl: string;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
deployMode: DeployMode;
|
||||||
|
stars: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventLogEntry {
|
export interface EventLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user