add live log
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+39
@@ -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
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user