From b7d523b4f4a87dcb8f4ed83f23e7dfb657dcf4dc Mon Sep 17 00:00:00 2001 From: Shatakshi Tiwari Date: Thu, 22 Oct 2026 11:12:32 +0530 Subject: [PATCH] add dashboard overview Signed-off-by: Shatakshi Tiwari --- app/[locale]/page.tsx | 5 +- app/api/dashboard/route.ts | 109 ++++++++++++++ components/app-sidebar.tsx | 4 + components/dashboard.tsx | 289 +++++++++++++++++++++++++++++++++++++ messages/de.json | 25 ++++ messages/en.json | 25 ++++ 6 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 app/api/dashboard/route.ts create mode 100644 components/dashboard.tsx diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 1bb842f..00a4d2d 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -7,6 +7,7 @@ import { redirect } from "next/navigation"; import { connection } from "next/server"; import { Suspense } from "react"; import { AppSidebar } from "@/components/app-sidebar"; +import { Dashboard } from "@/components/dashboard"; import { getAuthOptions, isAuthConfigured } from "@/lib/auth"; import { getAuthConfiguration } from "@/lib/auth-configuration"; @@ -41,6 +42,8 @@ async function AuthenticatedHome({ locale }: { locale: string }) { authConfigured={authConfigured} authMode={mode} providerName={providerName} - /> + > + + ); } diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..b351f2e --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,109 @@ +import { getToken } from "next-auth/jwt"; +import { NextResponse, type NextRequest } from "next/server"; +import { ApiError, fetchJson } from "@/lib/api"; + +type BackendDashboard = { + licenses_count?: unknown; + obligations_count?: unknown; + users_count?: unknown; + monthly_license_changes_count?: unknown; + risk_license_frequency?: unknown; +}; + +type BackendAudit = { + timestamp?: unknown; + type?: unknown; + user?: { display_name?: unknown; user_name?: unknown }; +}; + +function asCount(value: unknown) { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : 0; +} + +function asRiskFrequency(value: unknown) { + if (!Array.isArray(value)) return []; + + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const { risk, count } = entry as Record; + if (typeof risk !== "number" || !Number.isFinite(risk)) return []; + return [{ risk, count: asCount(count) }]; + }); +} + +function asRecentChanges(value: unknown) { + if (!Array.isArray(value)) return []; + + return value.map((entry) => { + const audit = entry as BackendAudit; + const user = audit.user; + return { + actor: + typeof user?.display_name === "string" && user.display_name.trim() + ? user.display_name + : typeof user?.user_name === "string" && user.user_name.trim() + ? user.user_name + : null, + type: typeof audit.type === "string" ? audit.type : null, + timestamp: typeof audit.timestamp === "string" ? audit.timestamp : null, + }; + }); +} + +export async function GET(request: NextRequest) { + const token = await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET, + }); + const accessToken = token?.backendTokens?.accessToken; + const baseUrl = process.env.LICENSEDB_API_URL; + + if (!accessToken) { + return NextResponse.json({ error: "Unauthenticated" }, { status: 401 }); + } + if (!baseUrl) { + return NextResponse.json({ error: "Backend unavailable" }, { status: 503 }); + } + + const headers = { Authorization: `Bearer ${accessToken}` }; + const apiUrl = baseUrl.replace(/\/+$/, ""); + + try { + const [dashboard, audits] = await Promise.all([ + fetchJson<{ data?: BackendDashboard }>(`${apiUrl}/dashboard`, { + headers, + cache: "no-store", + }), + fetchJson<{ data?: BackendAudit[] }>(`${apiUrl}/audits?limit=7&page=1`, { + headers, + cache: "no-store", + }), + ]); + + return NextResponse.json( + { + data: { + licensesCount: asCount(dashboard.data?.licenses_count), + obligationsCount: asCount(dashboard.data?.obligations_count), + usersCount: asCount(dashboard.data?.users_count), + monthlyLicenseChangesCount: asCount( + dashboard.data?.monthly_license_changes_count, + ), + riskLicenseFrequency: asRiskFrequency( + dashboard.data?.risk_license_frequency, + ), + recentChanges: asRecentChanges(audits.data), + }, + }, + { headers: { "Cache-Control": "private, no-store" } }, + ); + } catch (error) { + const status = error instanceof ApiError ? error.status : 502; + return NextResponse.json( + { error: "Unable to load dashboard data" }, + { status }, + ); + } +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index dc8878e..c194429 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { useSession } from "next-auth/react"; import { useTranslations } from "next-intl"; +import type { ReactNode } from "react"; import { ChevronDown, ChevronRight, @@ -64,10 +65,12 @@ export function AppSidebar({ authConfigured, authMode, providerName, + children, }: { authConfigured: boolean; authMode: AuthMode | null; providerName: string; + children: ReactNode; }) { const t = useTranslations("Sidebar"); const { data: session } = useSession(); @@ -215,6 +218,7 @@ export function AppSidebar({ title={t("openSidebar")} className="fixed top-4 left-4 z-20 md:hidden" /> + {children} diff --git a/components/dashboard.tsx b/components/dashboard.tsx new file mode 100644 index 0000000..d02b19e --- /dev/null +++ b/components/dashboard.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { + FileText, + MessageSquareMore, + Search, + ShieldCheck, + UsersRound, +} from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +type DashboardData = { + licensesCount: number; + obligationsCount: number; + usersCount: number; + monthlyLicenseChangesCount: number; + riskLicenseFrequency: Array<{ risk: number; count: number }>; + recentChanges: Array<{ + actor: string | null; + type: string | null; + timestamp: string | null; + }>; +}; + +const chartColors = ["#7b1a89", "#9ebc45", "#db8a34", "#6094bf", "#d76078"]; + +async function getDashboardData() { + const response = await fetch("/api/dashboard", { cache: "no-store" }); + if (!response.ok) throw new Error("Unable to load dashboard data"); + const payload = (await response.json()) as { data: DashboardData }; + return payload.data; +} + +function formatTimestamp(timestamp: string | null) { + if (!timestamp) return "-"; + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) + ? "-" + : new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +export function Dashboard() { + const t = useTranslations("Dashboard"); + const [selectedType, setSelectedType] = useState("all"); + const dashboardQuery = useQuery({ + queryKey: ["dashboard"], + queryFn: getDashboardData, + staleTime: 30_000, + }); + const data = dashboardQuery.data; + const metrics = [ + { + label: t("licenses"), + value: data?.licensesCount, + action: t("viewLicenses"), + icon: FileText, + }, + { + label: t("obligations"), + value: data?.obligationsCount, + action: t("viewObligations"), + icon: ShieldCheck, + }, + { + label: t("users"), + value: data?.usersCount, + action: t("viewUsers"), + icon: UsersRound, + }, + { + label: t("licenseChanges"), + value: data?.monthlyLicenseChangesCount, + action: t("viewLicenseChanges"), + icon: MessageSquareMore, + }, + ]; + const changeTypes = [ + ...new Set( + data?.recentChanges.flatMap((change) => + change.type ? [change.type] : [], + ) ?? [], + ), + ]; + const recentChanges = + data?.recentChanges.filter( + (change) => selectedType === "all" || change.type === selectedType, + ) ?? []; + const licenseSegments = + data?.riskLicenseFrequency.map((segment, index) => ({ + ...segment, + color: chartColors[index % chartColors.length], + percentage: data.licensesCount + ? (segment.count / data.licensesCount) * 100 + : 0, + })) ?? []; + + return ( +
+
+

{t("title")}

+ +
+ +
+
+ {metrics.map(({ label, value, action, icon: Icon }) => ( +
+
+
+

+ {dashboardQuery.isLoading ? "-" : value} +

+

{label}

+
+ + +
+ + {action} + +
+ ))} +
+ +
+
+
+

+ {t("recentChanges")} +

+ +
+ {dashboardQuery.isError ? ( +

+ {t("unavailable")} +

+ ) : recentChanges.length ? ( +
    + {recentChanges.map((change, index) => ( +
  • + + +
    +

    + {change.actor ?? t("unknownUser")} +

    +

    + {t("changedEntity", { + entity: change.type ?? t("item"), + })} +

    +
    + +
  • + ))} +
+ ) : ( +

+ {dashboardQuery.isLoading ? t("loading") : t("noRecentChanges")} +

+ )} +
+ +
+

+ {t("licensesTotal", { count: data?.licensesCount ?? 0 })} +

+
+ + + {licenseSegments.map((segment, index) => { + const offset = licenseSegments + .slice(0, index) + .reduce( + (total, previous) => total + previous.percentage, + 0, + ); + return ( + + ); + })} + + {licenseSegments.length ? ( +
    + {licenseSegments.map((segment) => ( +
  • + + + {t("riskLevel", { risk: segment.risk })} + + + {segment.count} ({segment.percentage.toFixed(1)}%) + +
  • + ))} +
+ ) : ( +

+ {dashboardQuery.isLoading + ? t("loading") + : t("noLicenseDistribution")} +

+ )} +
+
+
+
+
+ ); +} diff --git a/messages/de.json b/messages/de.json index df2d3b8..ebc4c6f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -37,6 +37,31 @@ "error": "Anmeldung derzeit nicht möglich. Bitte versuchen Sie es erneut.", "unavailable": "Die Anmeldung ist nicht konfiguriert. Wenden Sie sich an den Administrator." }, + "Dashboard": { + "title": "Armaturenbrett", + "search": "Suchen", + "summary": "LicenseDb-Zusammenfassung", + "licenses": "Lizenzen", + "obligations": "Verpflichtungen", + "users": "Benutzer", + "licenseChanges": "Lizenzaenderungen", + "viewLicenses": "Lizenzen anzeigen", + "viewObligations": "Verpflichtungen anzeigen", + "viewUsers": "Benutzer anzeigen", + "viewLicenseChanges": "Lizenzaenderungen anzeigen", + "recentChanges": "Letzte Aenderungen", + "changeType": "Aenderungstyp", + "allTypes": "Alle Typen", + "changedEntity": "hat {entity} geaendert", + "unknownUser": "Unbekannter Benutzer", + "item": "Element", + "loading": "Wird geladen...", + "unavailable": "Dashboard-Daten konnten nicht geladen werden.", + "noRecentChanges": "Keine aktuellen Aenderungen.", + "noLicenseDistribution": "Keine Daten zur Lizenzverteilung.", + "riskLevel": "Risiko {risk}", + "licensesTotal": "Lizenzen: {count}" + }, "Controls": { "language": "Sprache", "signIn": "Anmelden", diff --git a/messages/en.json b/messages/en.json index 7cc820d..1c32911 100644 --- a/messages/en.json +++ b/messages/en.json @@ -37,6 +37,31 @@ "error": "Unable to sign in right now. Please try again.", "unavailable": "Sign-in is not configured. Contact your administrator." }, + "Dashboard": { + "title": "Dashboard", + "search": "Search", + "summary": "LicenseDb summary", + "licenses": "Licenses", + "obligations": "Obligations", + "users": "Users", + "licenseChanges": "License Changes", + "viewLicenses": "View Licenses", + "viewObligations": "View Obligations", + "viewUsers": "View Users", + "viewLicenseChanges": "View License Changes", + "recentChanges": "Recent Changes", + "changeType": "Change type", + "allTypes": "All types", + "changedEntity": "changed {entity}", + "unknownUser": "Unknown user", + "item": "item", + "loading": "Loading...", + "unavailable": "Unable to load dashboard data.", + "noRecentChanges": "No recent changes.", + "noLicenseDistribution": "No license distribution data.", + "riskLevel": "Risk {risk}", + "licensesTotal": "Licenses: {count}" + }, "Controls": { "language": "Language", "signIn": "Sign in",