v0.1.11 — PDF Report-Export mit Preset-Auswahl + Recharts-Tooltip-Fix
This commit is contained in:
parent
807911d026
commit
fd118b40bf
8 changed files with 448 additions and 6 deletions
107
app/(app)/analytics/ExportPdfButton.tsx
Normal file
107
app/(app)/analytics/ExportPdfButton.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { FileDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: "summary", label: "Zusammenfassung" },
|
||||
{ key: "daily", label: "Hits pro Tag (Chart)" },
|
||||
{ key: "top", label: "Top Domains" },
|
||||
{ key: "country", label: "Geografische Verteilung" },
|
||||
{ key: "dead", label: "Tote Domains" },
|
||||
{ key: "perDomain", label: "Detail pro Domain" },
|
||||
{ key: "hits", label: "Letzte Aufrufe (bis 200)" },
|
||||
] as const;
|
||||
|
||||
const PRESETS: Record<string, Record<string, boolean>> = {
|
||||
basic: { summary: true, daily: true, top: true, country: true, dead: true, perDomain: false, hits: false },
|
||||
detailed: { summary: true, daily: true, top: true, country: true, dead: true, perDomain: true, hits: true },
|
||||
minimal: { summary: true, daily: false, top: true, country: false, dead: true, perDomain: false, hits: false },
|
||||
};
|
||||
|
||||
export function ExportPdfButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [days, setDays] = useState(30);
|
||||
const [title, setTitle] = useState("Domain-Redirect-Report");
|
||||
const [sel, setSel] = useState<Record<string, boolean>>(PRESETS.basic);
|
||||
|
||||
function applyPreset(name: keyof typeof PRESETS) {
|
||||
setSel(PRESETS[name]);
|
||||
}
|
||||
|
||||
function build() {
|
||||
const params = new URLSearchParams();
|
||||
params.set("days", String(days));
|
||||
params.set("title", title);
|
||||
params.set("print", "1");
|
||||
for (const s of SECTIONS) {
|
||||
params.set(s.key, sel[s.key] ? "1" : "0");
|
||||
}
|
||||
return `/analytics/report?${params.toString()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<FileDown className="mr-1 h-3 w-3" />
|
||||
PDF Export
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>PDF-Report erstellen</DialogTitle>
|
||||
<DialogDescription>Auswahl was rein soll, dann wird das Druck-Dialog geöffnet (→ "Als PDF speichern").</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Vorlage</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => applyPreset("minimal")}>Minimal</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => applyPreset("basic")}>Basic</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => applyPreset("detailed")}>Detailliert</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Titel</Label>
|
||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="days">Zeitraum (Tage)</Label>
|
||||
<Input id="days" type="number" min={1} max={365} value={days} onChange={(e) => setDays(Math.max(1, Math.min(365, Number(e.target.value) || 30)))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Inhalte</Label>
|
||||
<div className="space-y-1">
|
||||
{SECTIONS.map((s) => (
|
||||
<label key={s.key} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!sel[s.key]}
|
||||
onChange={(e) => setSel((cur) => ({ ...cur, [s.key]: e.target.checked }))}
|
||||
/>
|
||||
{s.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button asChild>
|
||||
<a href={build()} target="_blank" rel="noreferrer">Vorschau & PDF</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { getDb } from "@/lib/db";
|
|||
import { HitsLineChart } from "@/components/charts/HitsLineChart";
|
||||
import { TopDomainsBarChart } from "@/components/charts/TopDomainsBarChart";
|
||||
import { CountryPie } from "@/components/charts/CountryPie";
|
||||
import { ExportPdfButton } from "./ExportPdfButton";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -47,7 +48,11 @@ export default function AnalyticsPage() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Analytics" description="Hit-Statistiken letzte 30 Tage" />
|
||||
<PageHeader
|
||||
title="Analytics"
|
||||
description="Hit-Statistiken letzte 30 Tage"
|
||||
actions={<ExportPdfButton />}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 p-8">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
|
|
|
|||
232
app/(app)/analytics/report/ReportClient.tsx
Normal file
232
app/(app)/analytics/report/ReportClient.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { Printer, ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { HitsLineChart } from "@/components/charts/HitsLineChart";
|
||||
import { TopDomainsBarChart } from "@/components/charts/TopDomainsBarChart";
|
||||
import { CountryPie } from "@/components/charts/CountryPie";
|
||||
|
||||
type Sections = {
|
||||
summary: boolean;
|
||||
daily: boolean;
|
||||
top: boolean;
|
||||
country: boolean;
|
||||
perDomain: boolean;
|
||||
dead: boolean;
|
||||
hits: boolean;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ReportData = {
|
||||
days: number;
|
||||
sections: Sections;
|
||||
totalDomains: number;
|
||||
activeDomains: number;
|
||||
totalHits: number;
|
||||
uniqueIps: number;
|
||||
daily: { day: string; hits: number }[];
|
||||
top: { domain: string; hits: number }[];
|
||||
country: { country: string; hits: number }[];
|
||||
dead: { id: number; domain: string; target_url: string | null; created_at: number }[];
|
||||
perDomain: { id: number; domain: string; target_url: string | null; redirect_code: number; status: string; hits_period: number; hits_total: number; last_hit: number | null }[];
|
||||
recentHits: { ts: number; domain: string; country: string | null; path: string | null }[];
|
||||
generatedAt: number;
|
||||
};
|
||||
|
||||
export function ReportClient({ data, logo }: { data: ReportData; logo: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get("print") === "1") {
|
||||
setTimeout(() => window.print(), 800);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const s = data.sections;
|
||||
|
||||
return (
|
||||
<div className="report-root">
|
||||
<style>{`
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.report-root { background: white !important; color: #000 !important; }
|
||||
aside, [data-component="UpdateBanner"] { display: none !important; }
|
||||
.report-section { page-break-inside: avoid; }
|
||||
.report-page-break { page-break-after: always; }
|
||||
html, body { background: white !important; }
|
||||
}
|
||||
@page { margin: 1.5cm; }
|
||||
`}</style>
|
||||
|
||||
{/* Toolbar (only visible on screen) */}
|
||||
<div className="no-print sticky top-0 z-10 flex items-center justify-between border-b border-zinc-800/70 bg-zinc-950/95 px-6 py-3 backdrop-blur">
|
||||
<Link href="/analytics" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-zinc-100">
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Link>
|
||||
<button onClick={() => window.print()} className="flex items-center gap-2 rounded-md bg-cyan-500 px-4 py-2 text-sm font-medium text-zinc-950 hover:bg-cyan-400">
|
||||
<Printer className="h-4 w-4" /> Als PDF speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||
{/* Cover */}
|
||||
<header className="report-section flex items-center gap-4 border-b border-zinc-800/70 pb-6">
|
||||
<div>{logo}</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{s.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zeitraum: letzte {data.days} Tage • Erstellt: {new Date(data.generatedAt).toLocaleString("de-DE")}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{s.summary && (
|
||||
<section className="report-section space-y-3">
|
||||
<h2 className="text-xl font-semibold">Zusammenfassung</h2>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Stat label="Domains" value={data.totalDomains} sub={`${data.activeDomains} aktiv`} />
|
||||
<Stat label="Hits gesamt" value={data.totalHits} sub={`über ${data.days} Tage`} />
|
||||
<Stat label="Eindeutige Besucher" value={data.uniqueIps} sub="(IP-Hashes)" />
|
||||
<Stat label="Ø Hits/Tag" value={Math.round(data.totalHits / data.days)} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{s.daily && (
|
||||
<section className="report-section space-y-2">
|
||||
<h2 className="text-xl font-semibold">Hits pro Tag</h2>
|
||||
<div className="rounded-md border border-zinc-800/70 bg-zinc-900/30 p-4">
|
||||
<HitsLineChart data={data.daily} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{s.top && data.top.length > 0 && (
|
||||
<section className="report-section space-y-2">
|
||||
<h2 className="text-xl font-semibold">Top Domains</h2>
|
||||
<div className="rounded-md border border-zinc-800/70 bg-zinc-900/30 p-4">
|
||||
<TopDomainsBarChart data={data.top.slice(0, 15)} />
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<tr><th className="px-3 py-2 text-left">Domain</th><th className="px-3 py-2 text-right">Hits</th><th className="px-3 py-2 text-right">% gesamt</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/70">
|
||||
{data.top.slice(0, 30).map((r) => (
|
||||
<tr key={r.domain}>
|
||||
<td className="px-3 py-1.5 font-mono text-xs">{r.domain}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">{r.hits.toLocaleString("de-DE")}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">{data.totalHits ? ((r.hits / data.totalHits) * 100).toFixed(1) : "0"}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{s.country && data.country.length > 0 && (
|
||||
<section className="report-section space-y-2">
|
||||
<h2 className="text-xl font-semibold">Geografische Verteilung</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-md border border-zinc-800/70 bg-zinc-900/30 p-4">
|
||||
<CountryPie data={data.country.slice(0, 8)} />
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-muted-foreground"><tr><th className="px-3 py-2 text-left">Land</th><th className="px-3 py-2 text-right">Hits</th></tr></thead>
|
||||
<tbody className="divide-y divide-zinc-800/70">
|
||||
{data.country.map((c) => (
|
||||
<tr key={c.country}><td className="px-3 py-1.5 font-mono">{c.country}</td><td className="px-3 py-1.5 text-right tabular-nums">{c.hits.toLocaleString("de-DE")}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{s.dead && data.dead.length > 0 && (
|
||||
<section className="report-section space-y-2">
|
||||
<h2 className="text-xl font-semibold">Tote Domains</h2>
|
||||
<p className="text-xs text-muted-foreground">Aktive Domains ohne Hits in den letzten 90 Tagen — Kandidaten zum Kündigen.</p>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-muted-foreground"><tr><th className="px-3 py-2 text-left">Domain</th><th className="px-3 py-2 text-left">Ziel</th><th className="px-3 py-2 text-left">Angelegt</th></tr></thead>
|
||||
<tbody className="divide-y divide-zinc-800/70">
|
||||
{data.dead.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="px-3 py-1.5 font-mono">{d.domain}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground">{d.target_url || "—"}</td>
|
||||
<td className="px-3 py-1.5 text-xs text-muted-foreground">{new Date(d.created_at).toLocaleDateString("de-DE")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{s.perDomain && data.perDomain.length > 0 && (
|
||||
<section className="report-section space-y-2">
|
||||
<h2 className="text-xl font-semibold">Detailbericht pro Domain</h2>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Domain</th>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Code</th>
|
||||
<th className="px-3 py-2 text-left">Ziel</th>
|
||||
<th className="px-3 py-2 text-right">Hits ({data.days}d)</th>
|
||||
<th className="px-3 py-2 text-right">Hits gesamt</th>
|
||||
<th className="px-3 py-2 text-left">Letzter Hit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/70">
|
||||
{data.perDomain.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="px-3 py-1.5 font-mono text-xs">{d.domain}</td>
|
||||
<td className="px-3 py-1.5 text-xs">{d.status}</td>
|
||||
<td className="px-3 py-1.5 text-xs">{d.redirect_code}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-[11px] text-muted-foreground">{d.target_url || "—"}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">{d.hits_period.toLocaleString("de-DE")}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">{d.hits_total.toLocaleString("de-DE")}</td>
|
||||
<td className="px-3 py-1.5 text-xs text-muted-foreground">{d.last_hit ? new Date(d.last_hit).toLocaleString("de-DE") : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{s.hits && data.recentHits.length > 0 && (
|
||||
<section className="report-section space-y-2">
|
||||
<h2 className="text-xl font-semibold">Letzte Aufrufe</h2>
|
||||
<p className="text-xs text-muted-foreground">Bis zu 200 jüngste Hits im Berichts-Zeitraum.</p>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground"><tr><th className="px-2 py-1.5 text-left">Zeit</th><th className="px-2 py-1.5 text-left">Domain</th><th className="px-2 py-1.5 text-left">Land</th><th className="px-2 py-1.5 text-left">Pfad</th></tr></thead>
|
||||
<tbody className="divide-y divide-zinc-800/40">
|
||||
{data.recentHits.map((h, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1 font-mono">{new Date(h.ts).toLocaleString("de-DE")}</td>
|
||||
<td className="px-2 py-1 font-mono">{h.domain}</td>
|
||||
<td className="px-2 py-1">{h.country || "—"}</td>
|
||||
<td className="px-2 py-1 font-mono text-muted-foreground">{(h.path || "/").slice(0, 60)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="border-t border-zinc-800/70 pt-3 text-xs text-muted-foreground">
|
||||
Erstellt mit CoreX NexRedirect
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub }: { label: string; value: number; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800/70 bg-zinc-900/30 p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{value.toLocaleString("de-DE")}</p>
|
||||
{sub && <p className="text-[11px] text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
app/(app)/analytics/report/page.tsx
Normal file
85
app/(app)/analytics/report/page.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { ReportClient } from "./ReportClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SP = { [k: string]: string | string[] | undefined };
|
||||
|
||||
function parseDays(sp: SP): number {
|
||||
const v = sp.days;
|
||||
const s = Array.isArray(v) ? v[0] : v;
|
||||
const n = Number(s || 30);
|
||||
return Number.isFinite(n) && n > 0 ? Math.min(365, n) : 30;
|
||||
}
|
||||
|
||||
function flag(sp: SP, key: string, def = true): boolean {
|
||||
const v = sp[key];
|
||||
const s = Array.isArray(v) ? v[0] : v;
|
||||
if (s === undefined) return def;
|
||||
return s === "1" || s === "true";
|
||||
}
|
||||
|
||||
export default async function ReportPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const sp = await searchParams;
|
||||
const days = parseDays(sp);
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const db = getDb();
|
||||
|
||||
const sections = {
|
||||
summary: flag(sp, "summary"),
|
||||
daily: flag(sp, "daily"),
|
||||
top: flag(sp, "top"),
|
||||
country: flag(sp, "country"),
|
||||
perDomain: flag(sp, "perDomain", false),
|
||||
dead: flag(sp, "dead"),
|
||||
hits: flag(sp, "hits", false),
|
||||
title: typeof sp.title === "string" ? sp.title : "Domain-Redirect-Report",
|
||||
};
|
||||
|
||||
const totalDomains = (db.prepare("SELECT COUNT(*) AS n FROM domains").get() as { n: number }).n;
|
||||
const activeDomains = (db.prepare("SELECT COUNT(*) AS n FROM domains WHERE status='active'").get() as { n: number }).n;
|
||||
const totalHits = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE ts > ?").get(since) as { n: number }).n;
|
||||
const uniqueIps = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE ts > ?").get(since) as { n: number }).n;
|
||||
|
||||
const daily = db.prepare(`SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits FROM hits WHERE ts > ? GROUP BY day ORDER BY day`).all(since) as { day: string; hits: number }[];
|
||||
const top = db.prepare(`SELECT d.domain, COUNT(h.id) AS hits FROM hits h JOIN domains d ON d.id = h.domain_id WHERE h.ts > ? GROUP BY d.domain ORDER BY hits DESC`).all(since) as { domain: string; hits: number }[];
|
||||
const country = db.prepare(`SELECT COALESCE(country,'??') AS country, COUNT(*) AS hits FROM hits WHERE ts > ? GROUP BY country ORDER BY hits DESC`).all(since) as { country: string; hits: number }[];
|
||||
const dead = db.prepare(`SELECT d.id, d.domain, d.target_url, d.created_at FROM domains d WHERE d.status='active' AND NOT EXISTS (SELECT 1 FROM hits h WHERE h.domain_id = d.id AND h.ts > ?) ORDER BY d.created_at`).all(Date.now() - 90*24*60*60*1000) as { id: number; domain: string; target_url: string | null; created_at: number }[];
|
||||
|
||||
const perDomain = sections.perDomain
|
||||
? (db.prepare(`SELECT d.id, d.domain, d.target_url, d.redirect_code, d.status,
|
||||
(SELECT COUNT(*) FROM hits h WHERE h.domain_id = d.id AND h.ts > ?) AS hits_period,
|
||||
(SELECT COUNT(*) FROM hits h WHERE h.domain_id = d.id) AS hits_total,
|
||||
(SELECT MAX(ts) FROM hits h WHERE h.domain_id = d.id) AS last_hit
|
||||
FROM domains d ORDER BY hits_period DESC, d.domain`).all(since) as { id: number; domain: string; target_url: string | null; redirect_code: number; status: string; hits_period: number; hits_total: number; last_hit: number | null }[])
|
||||
: [];
|
||||
|
||||
const recentHits = sections.hits
|
||||
? (db.prepare(`SELECT h.ts, d.domain, h.country, h.path FROM hits h JOIN domains d ON d.id = h.domain_id WHERE h.ts > ? ORDER BY h.ts DESC LIMIT 200`).all(since) as { ts: number; domain: string; country: string | null; path: string | null }[])
|
||||
: [];
|
||||
|
||||
if (!totalDomains) notFound();
|
||||
|
||||
return (
|
||||
<ReportClient
|
||||
data={{
|
||||
days,
|
||||
sections,
|
||||
totalDomains,
|
||||
activeDomains,
|
||||
totalHits,
|
||||
uniqueIps,
|
||||
daily,
|
||||
top,
|
||||
country,
|
||||
dead,
|
||||
perDomain,
|
||||
recentHits,
|
||||
generatedAt: Date.now(),
|
||||
}}
|
||||
logo={<Logo size={48} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,8 +11,13 @@ export function CountryPie({ data }: { data: { country: string; hits: number }[]
|
|||
<Pie data={data} dataKey="hits" nameKey="country" outerRadius={100} label={({ country }) => country}>
|
||||
{data.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12, color: "#e5e7eb" }}
|
||||
itemStyle={{ color: "#e5e7eb" }}
|
||||
labelStyle={{ color: "#a1a1aa" }}
|
||||
formatter={(value: number, name: string) => [value.toLocaleString("de-DE"), name]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: "#e5e7eb" }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ export function HitsLineChart({ data }: { data: { day: string; hits: number }[]
|
|||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(82,95,122,0.2)" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 10, fill: "#a1a1aa" }} />
|
||||
<YAxis tick={{ fontSize: 10, fill: "#a1a1aa" }} />
|
||||
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12, color: "#e5e7eb" }}
|
||||
itemStyle={{ color: "#e5e7eb" }}
|
||||
labelStyle={{ color: "#a1a1aa" }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="hits" stroke="#22d3ee" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ export function TopDomainsBarChart({ data }: { data: { domain: string; hits: num
|
|||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(82,95,122,0.2)" />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: "#a1a1aa" }} />
|
||||
<YAxis type="category" dataKey="domain" tick={{ fontSize: 10, fill: "#a1a1aa" }} width={120} />
|
||||
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12, color: "#e5e7eb" }}
|
||||
itemStyle={{ color: "#e5e7eb" }}
|
||||
labelStyle={{ color: "#a1a1aa" }}
|
||||
/>
|
||||
<Bar dataKey="hits" fill="#34d399" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "corex-nexredirect",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"license": "MIT",
|
||||
"overrides": {
|
||||
"postcss": "^8.5.13",
|
||||
|
|
|
|||
Loading…
Reference in a new issue