feat: show GitHub commit history in topbar notifications

This commit is contained in:
KodekaTeam committed 2026-06-22 21:22:02 +07:00
1 parent 1435f5a6d7
commit 021163cf44
4 files changed
+388 -239

No files matched your search

+166 -235
View File
@@ -1,6 +1,10 @@
"use client";
import { HEALTH_URL } from "@/lib/api";
import {
HEALTH_URL,
topbarNotificationsApi,
type CommitHistoryNotificationRecord,
} from "@/lib/api";
import { useCurrentUser } from "@/lib/auth-state";
import { navigation } from "@/lib/navigation";
import {
@@ -26,20 +30,28 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type ConnectionState = "checking" | "online" | "offline" | "degraded";
type NotificationTone = "info" | "success" | "warning" | "error";
interface TopbarNotification {
id: string;
title: string;
message: string;
tone: NotificationTone;
timestamp: number;
read: boolean;
}
const TOPBAR_NOTIFICATIONS_KEY = "portainer-topbar-notifications";
const TOPBAR_COMMIT_READ_KEY = "doktainer-topbar-read-commit-notifications";
const MAX_TOPBAR_NOTIFICATIONS = 5;
function readStoredCommitNotificationIds() {
if (typeof window === "undefined") {
return new Set<string>();
}
try {
const stored = window.localStorage.getItem(TOPBAR_COMMIT_READ_KEY);
if (!stored) {
return new Set<string>();
}
const parsed = JSON.parse(stored) as string[];
return new Set(parsed.filter(Boolean));
} catch {
window.localStorage.removeItem(TOPBAR_COMMIT_READ_KEY);
return new Set<string>();
}
}
interface TopbarProps {
title: string;
subtitle?: string;
@@ -55,141 +67,70 @@ export default function Topbar({
const pathname = usePathname();
const router = useRouter();
const [time, setTime] = useState("");
const [notificationNow, setNotificationNow] = useState(0);
const [connectionState, setConnectionState] =
useState<ConnectionState>("checking");
const [query, setQuery] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">("dark");
const [notifications, setNotifications] = useState<TopbarNotification[]>([]);
const [notifications, setNotifications] = useState<
CommitHistoryNotificationRecord[]
>([]);
const [notificationsLoading, setNotificationsLoading] = useState(true);
const [notificationsError, setNotificationsError] = useState<string | null>(
null,
);
const [readNotificationIds, setReadNotificationIds] = useState<Set<string>>(
readStoredCommitNotificationIds,
);
const [notificationOpen, setNotificationOpen] = useState(false);
const searchRef = useRef<HTMLInputElement | null>(null);
const searchShellRef = useRef<HTMLDivElement | null>(null);
const notificationShellRef = useRef<HTMLDivElement | null>(null);
const notificationsRef = useRef<TopbarNotification[]>([]);
const lastConnectionNotificationRef = useRef<ConnectionState | null>(null);
const updateNotifications = useCallback(
(updater: (current: TopbarNotification[]) => TopbarNotification[]) => {
setNotifications((current) => {
const next = updater(current).slice(0, MAX_TOPBAR_NOTIFICATIONS);
notificationsRef.current = next;
window.localStorage.setItem(
TOPBAR_NOTIFICATIONS_KEY,
JSON.stringify(next),
);
return next;
});
},
[],
);
const pushNotification = useCallback(
({
title: nextTitle,
message,
tone = "info",
}: Omit<TopbarNotification, "id" | "timestamp" | "read">) => {
updateNotifications((current) => [
{
id: `topbar-notification-${Date.now()}-${current.length}`,
title: nextTitle,
message,
tone,
timestamp: Date.now(),
read: false,
},
...current,
]);
},
[updateNotifications],
);
const markNotificationsRead = useCallback(() => {
updateNotifications((current) => {
if (current.every((notification) => notification.read)) {
return current;
}
return current.map((notification) => ({
...notification,
read: true,
}));
setReadNotificationIds((current) => {
const next = new Set(current);
notifications.forEach((notification) => next.add(notification.id));
window.localStorage.setItem(
TOPBAR_COMMIT_READ_KEY,
JSON.stringify([...next].slice(0, 100)),
);
return next;
});
}, [updateNotifications]);
}, [notifications]);
const loadNotifications = useCallback(async () => {
setNotificationsLoading(true);
setNotificationsError(null);
try {
const response = await topbarNotificationsApi.listCommits();
setNotifications(response.data.slice(0, MAX_TOPBAR_NOTIFICATIONS));
} catch (error) {
setNotifications([]);
setNotificationsError(
error instanceof Error
? error.message
: "Failed to load commit history",
);
} finally {
setNotificationsLoading(false);
}
}, []);
useEffect(() => {
try {
const stored = window.localStorage.getItem(TOPBAR_NOTIFICATIONS_KEY);
const timeout = window.setTimeout(() => {
void loadNotifications();
}, 0);
if (stored) {
const parsed = JSON.parse(stored) as TopbarNotification[];
const initial = parsed.slice(0, MAX_TOPBAR_NOTIFICATIONS);
setNotifications(initial);
notificationsRef.current = initial;
return;
}
} catch {
window.localStorage.removeItem(TOPBAR_NOTIFICATIONS_KEY);
}
const now = Date.now();
const seeded: TopbarNotification[] = [
{
id: "seed-1",
title: "Welcome back",
message: currentUser?.name
? `${currentUser.name}, your workspace is ready.`
: "Your workspace is ready.",
tone: "info",
timestamp: now - 5 * 60_000,
read: false,
},
{
id: "seed-2",
title: "Quick search enabled",
message: "Press Ctrl/Cmd + K to jump between dashboard pages.",
tone: "info",
timestamp: now - 4 * 60_000,
read: false,
},
{
id: "seed-3",
title: "Theme switch ready",
message: "Use the sun or moon toggle to change the panel theme.",
tone: "info",
timestamp: now - 3 * 60_000,
read: false,
},
{
id: "seed-4",
title: "Connection monitor active",
message:
"The topbar now checks browser and API availability automatically.",
tone: "success",
timestamp: now - 2 * 60_000,
read: false,
},
{
id: "seed-5",
title: "Notification center active",
message: "The bell button shows the latest 5 topbar notifications.",
tone: "success",
timestamp: now - 60_000,
read: false,
},
];
setNotifications(seeded);
notificationsRef.current = seeded;
window.localStorage.setItem(
TOPBAR_NOTIFICATIONS_KEY,
JSON.stringify(seeded),
);
}, [currentUser?.name]);
return () => window.clearTimeout(timeout);
}, [loadNotifications]);
useEffect(() => {
const update = () => {
const now = new Date();
setNotificationNow(now.getTime());
setTime(
now.toLocaleTimeString("en-US", {
hour: "2-digit",
@@ -284,7 +225,9 @@ export default function Topbar({
}, []);
useEffect(() => {
void checkConnection();
const initialCheck = window.setTimeout(() => {
void checkConnection();
}, 0);
const handleOnline = () => {
void checkConnection();
@@ -302,6 +245,7 @@ export default function Topbar({
window.addEventListener("offline", handleOffline);
return () => {
window.clearTimeout(initialCheck);
window.clearInterval(interval);
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
@@ -352,9 +296,13 @@ export default function Topbar({
}, []);
useEffect(() => {
setQuery("");
setSearchOpen(false);
setNotificationOpen(false);
const timeout = window.setTimeout(() => {
setQuery("");
setSearchOpen(false);
setNotificationOpen(false);
}, 0);
return () => window.clearTimeout(timeout);
}, [pathname]);
useEffect(() => {
@@ -362,7 +310,8 @@ export default function Topbar({
return;
}
markNotificationsRead();
const timeout = window.setTimeout(markNotificationsRead, 0);
return () => window.clearTimeout(timeout);
}, [markNotificationsRead, notificationOpen]);
const shortcutLabel = useMemo(() => {
@@ -411,87 +360,32 @@ export default function Topbar({
router.push(href);
};
useEffect(() => {
if (connectionState === "checking") {
return;
}
if (lastConnectionNotificationRef.current === connectionState) {
return;
}
lastConnectionNotificationRef.current = connectionState;
if (connectionState === "online") {
pushNotification({
title: "Connection restored",
message: "Browser is online and the backend API is reachable.",
tone: "success",
});
return;
}
if (connectionState === "offline") {
pushNotification({
title: "Browser offline",
message:
"Network access was lost. Reconnect to restore dashboard features.",
tone: "error",
});
return;
}
pushNotification({
title: "Backend degraded",
message:
"Browser is online, but the API health check did not complete cleanly.",
tone: "warning",
});
}, [connectionState, pushNotification]);
const handleThemeToggle = () => {
const nextTheme = resolvedTheme === "light" ? "dark" : "light";
setStoredTheme(nextTheme);
pushNotification({
title: "Theme updated",
message: `Dashboard theme switched to ${nextTheme} mode.`,
tone: "info",
});
};
const unreadCount = useMemo(
() => notifications.filter((notification) => !notification.read).length,
[notifications],
() =>
notifications.filter(
(notification) => !readNotificationIds.has(notification.id),
).length,
[notifications, readNotificationIds],
);
const notificationToneMeta: Record<
NotificationTone,
{ dot: string; border: string; background: string }
> = {
info: {
dot: "#3b82f6",
border: "rgba(59, 130, 246, 0.2)",
background: "rgba(59, 130, 246, 0.08)",
},
success: {
dot: "#10b981",
border: "rgba(16, 185, 129, 0.2)",
background: "rgba(16, 185, 129, 0.08)",
},
warning: {
dot: "#f59e0b",
border: "rgba(245, 158, 11, 0.2)",
background: "rgba(245, 158, 11, 0.08)",
},
error: {
dot: "#ef4444",
border: "rgba(239, 68, 68, 0.2)",
background: "rgba(239, 68, 68, 0.08)",
},
const notificationToneMeta = {
dot: "#3b82f6",
border: "rgba(59, 130, 246, 0.2)",
background: "rgba(59, 130, 246, 0.08)",
};
const formatNotificationTimestamp = (timestamp: number) => {
const diff = Date.now() - timestamp;
const formatNotificationTimestamp = (timestamp: string) => {
const timeMs = new Date(timestamp).getTime();
if (!Number.isFinite(timeMs)) {
return "";
}
const diff = notificationNow - timeMs;
if (diff < 60_000) {
return "Just now";
@@ -505,31 +399,13 @@ export default function Topbar({
return `${Math.floor(diff / 3_600_000)}h ago`;
}
return new Date(timestamp).toLocaleDateString("en-US", {
return new Date(timeMs).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
const handleRefresh = () => {
const nextNotifications = [
{
id: `topbar-notification-${Date.now()}-${notificationsRef.current.length}`,
title: "Dashboard refresh",
message: "The current page is reloading.",
tone: "info" as const,
timestamp: Date.now(),
read: false,
},
...notificationsRef.current,
].slice(0, MAX_TOPBAR_NOTIFICATIONS);
notificationsRef.current = nextNotifications;
window.localStorage.setItem(
TOPBAR_NOTIFICATIONS_KEY,
JSON.stringify(nextNotifications),
);
setNotifications(nextNotifications);
window.location.reload();
};
@@ -987,19 +863,24 @@ export default function Topbar({
color: "var(--text-muted)",
}}
>
Latest {notifications.length} updates
Latest {notifications.length} commits
</div>
</div>
<button
type="button"
onClick={markNotificationsRead}
disabled={notifications.length === 0}
style={{
border: "none",
background: "transparent",
color: "#3b82f6",
color:
notifications.length === 0
? "var(--text-muted)"
: "#3b82f6",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
cursor:
notifications.length === 0 ? "default" : "pointer",
}}
>
Mark all read
@@ -1007,18 +888,64 @@ export default function Topbar({
</div>
<div style={{ maxHeight: 360, overflowY: "auto" }}>
{notifications.map((notification) => {
const toneMeta = notificationToneMeta[notification.tone];
{notificationsLoading && (
<div
style={{
padding: "18px 14px",
fontSize: 12,
color: "var(--text-muted)",
}}
>
Loading commit history...
</div>
)}
{!notificationsLoading && notificationsError && (
<div
style={{
padding: "18px 14px",
fontSize: 12,
color: "var(--text-muted)",
lineHeight: 1.5,
}}
>
{notificationsError}
</div>
)}
{!notificationsLoading &&
!notificationsError &&
notifications.length === 0 && (
<div
style={{
padding: "18px 14px",
fontSize: 12,
color: "var(--text-muted)",
}}
>
No recent commits found.
</div>
)}
{!notificationsLoading &&
!notificationsError &&
notifications.map((notification) => {
const isRead = readNotificationIds.has(notification.id);
const toneMeta = notificationToneMeta;
return (
<div
<a
key={notification.id}
href={notification.url || undefined}
target="_blank"
rel="noreferrer"
style={{
display: "block",
padding: "12px 14px",
borderBottom: "1px solid rgba(30, 42, 61, 0.18)",
background: notification.read
? "transparent"
: toneMeta.background,
background: isRead ? "transparent" : toneMeta.background,
color: "inherit",
textDecoration: "none",
}}
>
<div
@@ -1054,7 +981,11 @@ export default function Topbar({
fontSize: 12,
fontWeight: 700,
color: "var(--text-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={notification.title}
>
{notification.title}
</span>
@@ -1066,7 +997,7 @@ export default function Topbar({
}}
>
{formatNotificationTimestamp(
notification.timestamp,
notification.committedAt,
)}
</span>
</div>
@@ -1081,7 +1012,7 @@ export default function Topbar({
</p>
</div>
</div>
</div>
</a>
);
})}
</div>
+26
View File
@@ -1065,6 +1065,32 @@ export interface CreatedApiKeyRecord extends ApiKeyRecord {
rawKey: string;
}
export interface CommitHistoryNotificationRecord {
id: string;
sha: string;
shortSha: string;
title: string;
message: string;
authorName: string;
authorUsername: string;
authorAvatarUrl: string | null;
url: string;
committedAt: string;
}
export const topbarNotificationsApi = {
listCommits: () =>
get<{
success: boolean;
data: CommitHistoryNotificationRecord[];
meta: {
repository: string;
branch: string;
path: string;
};
}>("/notifications/commits", { timeoutMs: 15000 }),
};
export const apiKeys = {
list: () => get<{ success: boolean; data: ApiKeyRecord[] }>("/api-keys"),
+8 -4
View File
@@ -1,5 +1,5 @@
import "dotenv/config";
import Fastify from "fastify";
import Fastify, { FastifyError } from "fastify";
import cors from "@fastify/cors";
import jwt from "@fastify/jwt";
import rateLimit from "@fastify/rate-limit";
@@ -25,6 +25,7 @@ import { organizationsRoutes } from "./routes/organizations";
import { projectsRoutes } from "./routes/projects";
import { gitProviderRoutes } from "./routes/git-providers";
import { storageDestinationRoutes } from "./routes/storage-destinations";
import { commitHistoryRoutes } from "./routes/commit-history";
const PORT = parseInt(process.env.PORT || "4000");
const HOST = process.env.HOST || "0.0.0.0";
@@ -177,14 +178,17 @@ async function start() {
await app.register(storageDestinationRoutes, {
prefix: `${API_PREFIX}/storage-destinations`,
});
await app.register(commitHistoryRoutes, {
prefix: `${API_PREFIX}/notifications`,
});
// ── Error handler ─────────────────────────────────────
app.setErrorHandler((error: any, _req, reply) => {
app.setErrorHandler((error: FastifyError, _req, reply) => {
app.log.error(error);
const code = (error as any).statusCode || 500;
const code = error.statusCode || 500;
reply.status(code).send({
success: false,
error: (error as any).message || "Internal Server Error",
error: error.message || "Internal Server Error",
code,
});
});
+188
View File
@@ -0,0 +1,188 @@
import { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth";
const REPOSITORY = "DoktainerApp/doktainer";
const BRANCH = "main";
const PATH_FILTER = "";
const MAX_COMMITS = 5;
const CACHE_TTL_MS = 5 * 60 * 1000;
type GithubCommitPayload = {
sha?: unknown;
html_url?: unknown;
commit?: {
message?: unknown;
author?: {
name?: unknown;
date?: unknown;
};
};
author?: {
login?: unknown;
avatar_url?: unknown;
html_url?: unknown;
} | null;
};
type CommitHistoryItem = {
id: string;
sha: string;
shortSha: string;
title: string;
message: string;
authorName: string;
authorUsername: string;
authorAvatarUrl: string | null;
url: string;
committedAt: string;
};
type CommitHistoryCache = {
expiresAt: number;
payload: {
success: true;
data: CommitHistoryItem[];
meta: {
repository: string;
branch: string;
path: string;
};
};
};
let cache: CommitHistoryCache | null = null;
function normalizeRepository(value: string) {
return value.replace(/^https:\/\/github\.com\//i, "").replace(/\.git$/i, "");
}
function getCommitHistoryConfig() {
return {
repository: normalizeRepository(REPOSITORY),
branch: BRANCH,
path: PATH_FILTER,
};
}
function getFirstLine(value: unknown) {
return String(value ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
}
function mapGithubCommit(item: GithubCommitPayload): CommitHistoryItem | null {
const sha = String(item.sha ?? "").trim();
const committedAt = String(item.commit?.author?.date ?? "").trim();
const title = getFirstLine(item.commit?.message);
if (!sha || !committedAt || !title) {
return null;
}
const authorName = String(
item.author?.login ?? item.commit?.author?.name ?? "Unknown author",
);
const shortSha = sha.slice(0, 7);
return {
id: sha,
sha,
shortSha,
title,
message: `${authorName} committed ${shortSha}`,
authorName: String(item.commit?.author?.name ?? authorName),
authorUsername: String(item.author?.login ?? ""),
authorAvatarUrl:
typeof item.author?.avatar_url === "string"
? item.author.avatar_url
: null,
url: String(item.html_url ?? ""),
committedAt,
};
}
async function fetchCommitHistory() {
const config = getCommitHistoryConfig();
const [owner, repo] = config.repository.split("/");
if (!owner || !repo) {
throw new Error(
"Commit history repository must use the owner/repository format",
);
}
const url = new URL(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`,
);
url.searchParams.set("sha", config.branch);
url.searchParams.set("per_page", String(MAX_COMMITS));
if (config.path) {
url.searchParams.set("path", config.path);
}
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"User-Agent": "Doktainer",
"X-GitHub-Api-Version": "2022-11-28",
};
const response = await fetch(url, { headers });
const text = await response.text();
if (!response.ok) {
let message = text.trim();
try {
const payload = JSON.parse(text) as { message?: unknown };
message = String(payload.message ?? message);
} catch {
// Keep the plain response body.
}
throw new Error(
message || `GitHub request failed with status ${response.status}`,
);
}
const payload = JSON.parse(text) as unknown;
const commits = Array.isArray(payload)
? payload
.map((item) => mapGithubCommit(item as GithubCommitPayload))
.filter((item): item is CommitHistoryItem => Boolean(item))
: [];
return {
success: true as const,
data: commits,
meta: {
repository: config.repository,
branch: config.branch,
path: config.path,
},
};
}
export async function commitHistoryRoutes(app: FastifyInstance) {
app.get("/commits", { preHandler: authenticate }, async (_req, reply) => {
try {
if (cache && cache.expiresAt > Date.now()) {
return cache.payload;
}
const payload = await fetchCommitHistory();
cache = {
expiresAt: Date.now() + CACHE_TTL_MS,
payload,
};
return payload;
} catch (error) {
reply.status(502).send({
success: false,
error:
error instanceof Error
? error.message
: "Failed to load commit history",
});
}
});
}