add live log

This commit is contained in:
oiov
2025-03-23 14:13:05 +08:00
parent 0069ce80fb
commit 06c4a6e285
12 changed files with 354 additions and 7 deletions
+2
View File
@@ -4,6 +4,7 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import LiveLog from "../../dashboard/urls/live-logs";
import UserUrlsList from "../../dashboard/urls/url-list";
export const metadata = constructMetadata({
@@ -24,6 +25,7 @@ export default async function DashboardPage() {
link="/docs/short-urls"
linkText="Short urls."
/>
<LiveLog admin={true} />
<UserUrlsList
user={{ id: user.id, name: user.name || "", apiKey: user.apiKey || "" }}
action="/api/url/admin"
@@ -0,0 +1,206 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import useSWR from "swr";
import { LOGS_LIMITEs_ENUMS } from "@/lib/enums";
import { fetcher } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Icons } from "@/components/shared/icons";
export interface LogEntry {
target: string;
slug: string;
ip: string;
click: number;
city?: string;
country?: string;
updatedAt: string;
createdAt: string;
}
export default function LiveLog({ admin }: { admin: boolean }) {
const [isLive, setIsLive] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [limitDiplay, setLimitDisplay] = useState(100);
const { data: newLogs, error } = useSWR<LogEntry[], Error>(
isLive ? `/api/url/admin/live-log?admin=${admin}` : null,
fetcher,
{
refreshInterval: 5000,
dedupingInterval: 2000,
},
);
// 追加和去重逻辑
useEffect(() => {
if (newLogs) {
setLogs((prevLogs) => {
const logMap = new Map<string, LogEntry>(
prevLogs.map((log) => [`${log.ip}-${log.slug}`, log]),
);
// 添加或更新新日志
newLogs.forEach((log) => {
const key = `${log.ip}-${log.slug}`;
const existing = logMap.get(key);
if (
!existing ||
new Date(log.updatedAt) > new Date(existing.updatedAt)
) {
logMap.set(key, log);
}
});
// 转换为数组,排序并限制总数
return Array.from(logMap.values())
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.slice(0, limitDiplay);
});
}
}, [newLogs]);
const toggleLive = () => setIsLive((prev) => !prev);
return (
<Card className="mx-auto w-full">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base text-gray-800">Live Log</CardTitle>
<CardDescription>
Real-time updates of short URL visits.
</CardDescription>
</div>
<button
onClick={toggleLive}
className={`ml-auto flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-colors hover:border-blue-600 hover:text-blue-600 ${
isLive ? "border-blue-600 text-blue-500" : ""
}`}
>
<Icons.CirclePlay className="h-4 w-4" /> {isLive ? "Stop" : "Live"}
</button>
<button
onClick={() => setLogs([])}
className={`ml-2 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
logs.length > 0
? "hover:border-yellow-400 hover:text-yellow-400"
: ""
}`}
disabled={logs.length === 0}
>
<Icons.trash className="h-4 w-4" />
</button>
</div>
</CardHeader>
<CardContent>
{error ? (
<div className="text-center text-red-500">{error.message}</div>
) : logs.length === 0 && !newLogs ? (
<Skeleton className="h-8 w-full" />
) : (
// <></>
<div className="scrollbar-hidden h-96 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-1/12">No.</TableHead>
<TableHead className="w-1/12">Url</TableHead>
<TableHead className="">Target</TableHead>
<TableHead className="w-1/12">IP</TableHead>
<TableHead className="w-1/6">Location</TableHead>
<TableHead className="w-1/12">Clicks</TableHead>
<TableHead className="w-1/12">Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence initial={false}>
{logs.map((log, index) => (
<motion.tr
key={`${log.ip}-${log.slug}`}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="h-3 border-b border-dashed border-gray-100 text-xs hover:bg-gray-50 dark:border-gray-800"
>
<TableCell className="">{logs.length - index}</TableCell>
<TableCell className="font-midium">{log.slug}</TableCell>
<TableCell className="max-w-10 truncate hover:underline">
<a href={log.target} target="_blank" title={log.target}>
{log.target}
</a>
</TableCell>
<TableCell className="">{log.ip}</TableCell>
<TableCell className="max-w-6 truncate">
{decodeURIComponent(
log.city ? `${log.city}, ${log.country}` : "-",
)}
</TableCell>
<TableCell className="">{log.click}</TableCell>
<TableCell>
{new Date(log.updatedAt).toLocaleTimeString()}
</TableCell>
</motion.tr>
))}
</AnimatePresence>
</TableBody>
</Table>
</div>
)}
{isLive && (
<div className="flex w-full items-center justify-end gap-2 p-2 text-sm text-gray-500">
<p>{logs.length}</p> of
<Select
onValueChange={(value: string) => {
setLimitDisplay(Number(value));
}}
name="expiration"
defaultValue={limitDiplay.toString()}
>
<SelectTrigger className="w-20 shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{LOGS_LIMITEs_ENUMS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p>total logs</p>
</div>
)}
</CardContent>
</Card>
);
}
+2
View File
@@ -4,6 +4,7 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import LiveLog from "./live-logs";
import UserUrlsList from "./url-list";
export const metadata = constructMetadata({
@@ -24,6 +25,7 @@ export default async function DashboardPage() {
link="/docs/short-urls"
linkText="Short urls."
/>
<LiveLog admin={false} />
<UserUrlsList
user={{ id: user.id, name: user.name || "", apiKey: user.apiKey || "" }}
action="/api/url"
@@ -38,7 +38,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import CountUpFn from "@/components/dashboard/count-up";
import StatusDot from "@/components/dashboard/status-dot";
import { FormType } from "@/components/forms/record-form";
import { UrlForm } from "@/components/forms/url-form";
+33
View File
@@ -0,0 +1,33 @@
import { getUrlMetaLiveLog } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
const url = new URL(req.url);
const isAdmin = url.searchParams.get("admin");
if (isAdmin === "true") {
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Unauthorized",
});
}
}
const logs = await getUrlMetaLiveLog(
isAdmin === "true" ? undefined : user.id,
);
return Response.json(logs);
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
statusText: error.statusText || "Server error",
});
}
}
+6 -5
View File
@@ -10,6 +10,7 @@ import {
ChevronLeft,
ChevronRight,
CircleHelp,
CirclePlay,
Copy,
File,
FileText,
@@ -59,7 +60,6 @@ export const Icons = {
copy: Copy,
camera: Camera,
fileText: FileText,
dashboard: LayoutPanelLeft,
ellipsis: MoreVertical,
github: ({ ...props }: LucideProps) => (
@@ -139,6 +139,7 @@ export const Icons = {
link: Link,
mail: Mail,
bug: Bug,
CirclePlay: CirclePlay,
// help: CircleHelp,
outLink: ({ ...props }: LucideProps) => (
<svg
@@ -176,10 +177,10 @@ export const Icons = {
r="32.252"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#8c9eff"></stop>
<stop offset=".368" stop-color="#889af8"></stop>
<stop offset=".889" stop-color="#7e8fe6"></stop>
<stop offset="1" stop-color="#7b8ce1"></stop>
<stop offset="0" stopColor="#8c9eff"></stop>
<stop offset=".368" stopColor="#889af8"></stop>
<stop offset=".889" stopColor="#7e8fe6"></stop>
<stop offset="1" stopColor="#7b8ce1"></stop>
</radialGradient>
<path
fill="url(#La9SoowKGoEUHOnYdJMSEa_2mIgusGquJFz_gr1)"
+32
View File
@@ -206,3 +206,35 @@ async function incrementClick(id) {
},
});
}
export async function getUrlMetaLiveLog(userId?: string) {
const whereClause = userId ? { userUrl: { userId } } : {};
const logs = await prisma.urlMeta.findMany({
take: 10,
where: whereClause,
orderBy: { updatedAt: "desc" },
select: {
ip: true,
click: true,
updatedAt: true,
createdAt: true,
city: true,
country: true,
userUrl: {
select: {
url: true,
target: true,
},
},
},
});
const formattedLogs = logs.map((log) => ({
...log,
slug: log.userUrl.url,
target: log.userUrl.target,
}));
return formattedLogs;
}
+23
View File
@@ -189,3 +189,26 @@ export const reservedDomains = [
"test1.wr.do",
"demo1.wr.do",
];
export const LOGS_LIMITEs_ENUMS = [
{
value: "50",
label: "50",
},
{
value: "100",
label: "100",
},
{
value: "200",
label: "200",
},
{
value: "500",
label: "500",
},
{
value: "1000",
label: "1000",
},
];
+1
View File
@@ -69,6 +69,7 @@
"contentlayer2": "^0.5.0",
"crypto": "^1.0.1",
"date-fns": "^3.6.0",
"framer-motion": "^12.5.0",
"lucide-react": "^0.414.0",
"lucide-static": "^0.460.0",
"minimist": "^1.2.8",
+39
View File
@@ -158,6 +158,9 @@ importers:
date-fns:
specifier: ^3.6.0
version: 3.6.0
framer-motion:
specifier: ^12.5.0
version: 12.5.0(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
lucide-react:
specifier: ^0.414.0
version: 0.414.0(react@18.3.1)
@@ -4664,6 +4667,20 @@ packages:
react-dom:
optional: true
framer-motion@12.5.0:
resolution: {integrity: sha512-buPlioFbH9/W7rDzYh1C09AuZHAk2D1xTA1BlounJ2Rb9aRg84OXexP0GLd+R83v0khURdMX7b5MKnGTaSg5iA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fs-extra@9.1.0:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
@@ -5661,6 +5678,12 @@ packages:
resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==}
engines: {node: '>=16 || 14 >=14.17'}
motion-dom@12.5.0:
resolution: {integrity: sha512-uH2PETDh7m+Hjd1UQQ56yHqwn83SAwNjimNPE/kC+Kds0t4Yh7+29rfo5wezVFpPOv57U4IuWved5d1x0kNhbQ==}
motion-utils@12.5.0:
resolution: {integrity: sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -12482,6 +12505,16 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
framer-motion@12.5.0(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
motion-dom: 12.5.0
motion-utils: 12.5.0
tslib: 2.6.2
optionalDependencies:
'@emotion/is-prop-valid': 0.8.8
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
fs-extra@9.1.0:
dependencies:
at-least-node: 1.0.0
@@ -13815,6 +13848,12 @@ snapshots:
minipass@7.0.4: {}
motion-dom@12.5.0:
dependencies:
motion-utils: 12.5.0
motion-utils@12.5.0: {}
mri@1.2.0: {}
ms@2.1.2: {}
+1 -1
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -109,6 +109,15 @@
background: hsla(0, 0%, 59%, 0.524);
}
.scrollbar-hidden {
-ms-overflow-style: none; /* IE 和 Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hidden::-webkit-scrollbar {
display: none; /* Chrome, Safari, 和 Opera */
}
.grids {
background-image: linear-gradient(
0deg,