Compare commits

...

3 Commits

Author SHA1 Message Date
7571316685 feat: 添加 ip 追踪 2026-06-09 18:43:54 +08:00
ca654754ae fix: 修复前端快速滑动时多个网络请求 2026-06-09 17:32:35 +08:00
681e3c7493 fix: 修复 fileid索引 2026-05-17 18:42:19 +08:00
13 changed files with 464 additions and 31 deletions

View File

@ -14,20 +14,22 @@ import { useStarToggle } from '../hooks/useStarToggle';
const inFlightJsonRequests = new Map<string, Promise<unknown>>();
const fetchJson = async <T,>(url: string, signal?: AbortSignal): Promise<T> => {
const response = await fetch(url, { cache: 'no-store', signal });
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
};
const fetchJsonOnce = async <T,>(url: string): Promise<T> => {
const existingRequest = inFlightJsonRequests.get(url);
if (existingRequest) {
return existingRequest as Promise<T>;
}
const request = fetch(url, { cache: 'no-store' })
.then(async (response) => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
})
const request = fetchJson<T>(url)
.finally(() => {
inFlightJsonRequests.delete(url);
});
@ -36,6 +38,12 @@ const fetchJsonOnce = async <T,>(url: string): Promise<T> => {
return request as Promise<T>;
};
const isAbortError = (error: unknown) => (
error instanceof DOMException && error.name === 'AbortError'
);
const HOURLY_RECORDS_REQUEST_DEBOUNCE_MS = 120;
interface ScreenshotsTabProps {
hostname: string;
selectedDate: string | null;
@ -53,6 +61,8 @@ export default function ScreenshotsTab({
}: ScreenshotsTabProps) {
const timeDistributionRequestIdRef = useRef(0);
const hourlyRecordsRequestIdRef = useRef(0);
const hourlyRecordsAbortControllerRef = useRef<AbortController | null>(null);
const hourlyRecordsDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 状态管理
const [timeDistribution, setTimeDistribution] = useState<TimeDistributionPoint[]>([]);
@ -83,6 +93,25 @@ export default function ScreenshotsTab({
const { updatingStars, toggleStar } = useStarToggle();
const clearPendingHourlyRecordsRequest = () => {
if (hourlyRecordsDebounceTimerRef.current) {
clearTimeout(hourlyRecordsDebounceTimerRef.current);
hourlyRecordsDebounceTimerRef.current = null;
}
};
const scheduleHourlyRecordsFetch = (
startTime: number,
endTime: number,
options?: { targetRecordId?: string, keepSelection?: boolean }
) => {
clearPendingHourlyRecordsRequest();
hourlyRecordsDebounceTimerRef.current = setTimeout(() => {
hourlyRecordsDebounceTimerRef.current = null;
fetchHourlyRecords(startTime, endTime, options);
}, HOURLY_RECORDS_REQUEST_DEBOUNCE_MS);
};
// 获取时间分布数据
const fetchTimeDistribution = async () => {
const requestId = ++timeDistributionRequestIdRef.current;
@ -112,19 +141,23 @@ export default function ScreenshotsTab({
// 获取小时记录
const fetchHourlyRecords = async (startTime: number, endTime: number, options?: { targetRecordId?: string, keepSelection?: boolean }) => {
clearPendingHourlyRecordsRequest();
const requestId = ++hourlyRecordsRequestIdRef.current;
const requestUrl = `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}`;
hourlyRecordsAbortControllerRef.current?.abort();
const abortController = new AbortController();
hourlyRecordsAbortControllerRef.current = abortController;
try {
setLoadingRecords(true);
setShowDetailTimeline(true);
const data = await fetchJsonOnce<{
const data = await fetchJson<{
lastUpdate: string | null;
records: ScreenRecord[];
}>(requestUrl);
}>(requestUrl, abortController.signal);
if (requestId !== hourlyRecordsRequestIdRef.current) {
if (requestId !== hourlyRecordsRequestIdRef.current || abortController.signal.aborted) {
return;
}
@ -134,6 +167,10 @@ export default function ScreenshotsTab({
setTimeRange({ min: startTime * 1000, max: endTime * 1000 });
requestAnimationFrame(() => {
if (requestId !== hourlyRecordsRequestIdRef.current || abortController.signal.aborted) {
return;
}
if (options?.targetRecordId) {
const found = newRecords.find((r: ScreenRecord) => r.id === options.targetRecordId);
if (found) {
@ -151,11 +188,19 @@ export default function ScreenshotsTab({
}
});
} catch (error) {
if (isAbortError(error)) {
return;
}
if (requestId === hourlyRecordsRequestIdRef.current) {
console.error('获取记录数据失败:', error);
}
} finally {
if (requestId === hourlyRecordsRequestIdRef.current) {
if (hourlyRecordsAbortControllerRef.current === abortController) {
hourlyRecordsAbortControllerRef.current = null;
}
if (requestId === hourlyRecordsRequestIdRef.current && !abortController.signal.aborted) {
setLoadingRecords(false);
}
}
@ -273,12 +318,8 @@ export default function ScreenshotsTab({
// 事件处理函数
const onHourlySliderChange = (newValue: number) => {
const selectedSec = Math.floor(newValue / 3600000) * 3600;
// 使用 setTimeout 避免在渲染过程中更新状态
setTimeout(() => {
setHourlySliderValue(newValue);
fetchHourlyRecords(selectedSec, selectedSec + 3600);
}, 0);
scheduleHourlyRecordsFetch(selectedSec, selectedSec + 3600);
};
const onDetailedSliderChange = (newValue: number) => {
@ -443,6 +484,19 @@ export default function ScreenshotsTab({
}, [prevFrame, nextFrame]);
// Effects
useEffect(() => {
return () => {
if (hourlyRecordsDebounceTimerRef.current) {
clearTimeout(hourlyRecordsDebounceTimerRef.current);
hourlyRecordsDebounceTimerRef.current = null;
}
hourlyRecordsRequestIdRef.current += 1;
hourlyRecordsAbortControllerRef.current?.abort();
hourlyRecordsAbortControllerRef.current = null;
};
}, []);
useEffect(() => {
fetchTimeDistribution();
}, [hostname]);
@ -797,6 +851,26 @@ export default function ScreenshotsTab({
</div>
</div>
</div>
<div className="w-full mt-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3"> IP</h3>
<div className="bg-gray-50 dark:bg-gray-700 rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 bg-white dark:bg-gray-800 rounded-md shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">IP </div>
<div className="font-medium text-gray-900 dark:text-white break-all">
{selectedRecord.ip || '未记录'}
</div>
</div>
<div className="p-4 bg-white dark:bg-gray-800 rounded-md shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1"></div>
<div className="font-medium text-gray-900 dark:text-white break-all">
{selectedRecord.ipLocation || (selectedRecord.ip ? '未知归属地' : '未记录')}
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>

View File

@ -4,6 +4,8 @@ import { storeFile } from '@/lib/fileStorage'
import { push } from '@/lib/push'
import { withCors } from '@/lib/middleware'
import { filterIgnoredWindows } from '@/lib/windowFilters'
import { getClientIp } from '@/lib/clientIp'
import { getIpGeodataLabel } from '@/lib/geodata'
import ffmpeg from 'fluent-ffmpeg'
import { writeFileSync, unlinkSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
@ -27,6 +29,8 @@ async function handleScreenshotUpload(req: NextRequest) {
return NextResponse.json({ error: '缺少主机名' }, { status: 400 })
}
const clientIp = getClientIp(req)
const formData = await req.formData()
const files: File[] = []
const rawWindowsInfo: WindowInfo[] = JSON.parse(formData.get('windows_info') as string || '[]')
@ -127,6 +131,7 @@ async function handleScreenshotUpload(req: NextRequest) {
const newRecord = await prisma.record.create({
data: {
hostname,
ip: clientIp,
timestamp: new Date(),
windows: {
create: windowsInfo
@ -226,6 +231,7 @@ async function handleGetScreenshots(req: NextRequest) {
select: {
id: true,
timestamp: true,
ip: true,
isStarred: true,
windows: {
select: {
@ -262,6 +268,7 @@ async function handleGetScreenshots(req: NextRequest) {
// Convert BigInt to string in windows.memory field
const serializedRecords = records.map(record => ({
...record,
ipLocation: getIpGeodataLabel(record.ip),
windows: record.windows.map(window => ({
...window,
memory: window.memory.toString()

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { withCors } from '@/lib/middleware'
import { getIpGeodataLabel } from '@/lib/geodata'
// 获取指定主机的星标记录
async function handleGetStarredRecords(req: NextRequest) {
@ -49,6 +50,7 @@ async function handleGetStarredRecords(req: NextRequest) {
})
const serializedRecords = records.map(record => ({
...record,
ipLocation: getIpGeodataLabel(record.ip),
windows: record.windows.map(window => ({
...window,
memory: window.memory.toString()

View File

@ -13,6 +13,8 @@ export interface Window {
export interface ScreenRecord {
id: string;
timestamp: string;
ip: string | null;
ipLocation: string | null;
isStarred: boolean;
windows: Window[];
screenshots: Screenshot[];

View File

@ -1,8 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { getFileByObjectName } from '@/lib/fileStorage'
import { Readable } from 'stream'
import { getFileStreamByObjectName } from '@/lib/fileStorage'
import { prisma } from '@/lib/prisma'
import { withCors } from '@/lib/middleware'
export const runtime = 'nodejs'
interface ScreenshotFileMetadata {
objectName: string
filename: string
contentType: string
fileSize: number
}
const MAX_SCREENSHOT_METADATA_CACHE_SIZE = 5000
const screenshotMetadataCache = new Map<string, ScreenshotFileMetadata>()
function cacheScreenshotMetadata(fileId: string, metadata: ScreenshotFileMetadata) {
if (screenshotMetadataCache.size >= MAX_SCREENSHOT_METADATA_CACHE_SIZE) {
const oldestFileId = screenshotMetadataCache.keys().next().value
if (oldestFileId) {
screenshotMetadataCache.delete(oldestFileId)
}
}
screenshotMetadataCache.set(fileId, metadata)
}
async function findScreenshotMetadata(fileId: string): Promise<ScreenshotFileMetadata | null> {
const cached = screenshotMetadataCache.get(fileId)
if (cached) {
return cached
}
const screenshot = await prisma.screenshot.findFirst({
where: { fileId },
select: {
objectName: true,
filename: true,
contentType: true,
fileSize: true
}
})
if (screenshot) {
cacheScreenshotMetadata(fileId, screenshot)
}
return screenshot
}
function createScreenshotHeaders(fileId: string, screenshot: ScreenshotFileMetadata) {
const headers = new Headers({
'Content-Type': resolveScreenshotContentType(screenshot),
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': `"${fileId}"`,
'X-Content-Type-Options': 'nosniff'
})
if (screenshot.fileSize > 0) {
headers.set('Content-Length', String(screenshot.fileSize))
}
return headers
}
function resolveScreenshotContentType(screenshot: ScreenshotFileMetadata) {
const storedContentType = screenshot.contentType?.trim()
const filePath = `${screenshot.filename} ${screenshot.objectName}`.toLowerCase()
if (storedContentType && storedContentType !== 'image/webp') {
return storedContentType
}
if (filePath.includes('.avif')) return 'image/avif'
if (filePath.includes('.webp')) return 'image/webp'
if (filePath.includes('.png')) return 'image/png'
if (filePath.includes('.jpg') || filePath.includes('.jpeg')) return 'image/jpeg'
return storedContentType || 'image/webp'
}
async function handleScreenshotFile(req: NextRequest) {
try {
const pathSegments = req.nextUrl.pathname.split('/')
@ -13,27 +91,29 @@ async function handleScreenshotFile(req: NextRequest) {
return NextResponse.json({ error: '缺少文件ID' }, { status: 400 })
}
// 从数据库查找 objectName
const screenshot = await prisma.screenshot.findFirst({
where: { fileId },
select: { objectName: true }
})
const screenshot = await findScreenshotMetadata(fileId)
if (!screenshot) {
return NextResponse.json({ error: '截图不存在' }, { status: 404 })
}
const file = await getFileByObjectName(screenshot.objectName)
const headers = createScreenshotHeaders(fileId, screenshot)
if (req.headers.get('if-none-match') === headers.get('ETag')) {
headers.delete('Content-Length')
return new NextResponse(null, { status: 304, headers })
}
const file = await getFileStreamByObjectName(screenshot.objectName, {
contentType: resolveScreenshotContentType(screenshot),
filename: screenshot.filename,
size: screenshot.fileSize
})
if (!file) {
return NextResponse.json({ error: '文件不存在' }, { status: 404 })
}
return new NextResponse(file.buffer, {
headers: {
'Content-Type': file.contentType || 'image/webp',
'Cache-Control': 'public, max-age=31536000',
}
})
return new NextResponse(Readable.toWeb(file.stream) as unknown as BodyInit, { headers })
} catch (error) {
console.error('获取截图失败:', error)

View File

@ -15,6 +15,7 @@
"date-fns": "^4.1.0",
"dotenv-cli": "^8.0.0",
"fluent-ffmpeg": "^2.1.3",
"ip2region": "^2.3.0",
"lucide-react": "^0.525.0",
"minio": "^8.0.5",
"multer": "^2.0.1",
@ -381,6 +382,8 @@
"internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ip2region": ["ip2region@2.3.0", "https://registry.npmmirror.com/ip2region/-/ip2region-2.3.0.tgz", { "peerDependencies": { "@types/node": "*" } }, "sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg=="],
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],

148
lib/clientIp.ts Normal file
View File

@ -0,0 +1,148 @@
import { isIP } from 'net'
import type { NextRequest } from 'next/server'
const DIRECT_IP_HEADERS = [
'cf-connecting-ip',
'true-client-ip',
'fly-client-ip',
'fastly-client-ip'
]
const FALLBACK_IP_HEADERS = [
'x-real-ip',
'x-client-ip'
]
const LIST_IP_HEADERS = [
'x-forwarded-for',
'x-original-forwarded-for'
]
export function getClientIp(req: NextRequest) {
return getClientIpFromHeaders(req.headers)
}
export function getClientIpFromHeaders(headers: Headers) {
const candidates: string[] = []
for (const header of DIRECT_IP_HEADERS) {
const value = headers.get(header)
if (value) {
candidates.push(value)
}
}
const forwarded = headers.get('forwarded')
if (forwarded) {
candidates.push(...parseForwardedHeader(forwarded))
}
for (const header of LIST_IP_HEADERS) {
const value = headers.get(header)
if (value) {
candidates.push(...value.split(','))
}
}
for (const header of FALLBACK_IP_HEADERS) {
const value = headers.get(header)
if (value) {
candidates.push(value)
}
}
for (const candidate of candidates) {
const ip = normalizeIpCandidate(candidate)
if (ip) {
return ip
}
}
return null
}
export function normalizeIpCandidate(value?: string | null) {
if (!value) {
return null
}
let candidate = value.trim()
if (!candidate || candidate.toLowerCase() === 'unknown') {
return null
}
if (candidate.startsWith('"') && candidate.endsWith('"')) {
candidate = candidate.slice(1, -1)
}
if (candidate.startsWith('[')) {
const closingBracketIndex = candidate.indexOf(']')
if (closingBracketIndex > 0) {
candidate = candidate.slice(1, closingBracketIndex)
}
} else {
const colonCount = (candidate.match(/:/g) || []).length
if (colonCount === 1 && candidate.includes('.')) {
candidate = candidate.slice(0, candidate.lastIndexOf(':'))
}
}
const zoneIndex = candidate.indexOf('%')
if (zoneIndex > -1) {
candidate = candidate.slice(0, zoneIndex)
}
candidate = candidate.trim()
if (candidate.toLowerCase().startsWith('::ffff:')) {
const ipv4 = candidate.slice(7)
if (isIP(ipv4) === 4) {
return ipv4
}
}
return isIP(candidate) ? candidate : null
}
export function isPrivateIp(ip: string) {
const normalizedIp = normalizeIpCandidate(ip)
if (!normalizedIp) {
return false
}
if (isIP(normalizedIp) === 4) {
const [first, second] = normalizedIp.split('.').map(Number)
return first === 10 ||
first === 127 ||
first === 0 ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(first === 169 && second === 254) ||
(first === 100 && second >= 64 && second <= 127)
}
const lowerIp = normalizedIp.toLowerCase()
return lowerIp === '::1' ||
lowerIp === '::' ||
lowerIp.startsWith('fc') ||
lowerIp.startsWith('fd') ||
lowerIp.startsWith('fe8') ||
lowerIp.startsWith('fe9') ||
lowerIp.startsWith('fea') ||
lowerIp.startsWith('feb')
}
function parseForwardedHeader(value: string) {
const candidates: string[] = []
for (const entry of value.split(',')) {
for (const segment of entry.split(';')) {
const [key, rawValue] = segment.split('=')
if (key?.trim().toLowerCase() === 'for' && rawValue) {
candidates.push(rawValue.trim())
}
}
}
return candidates
}

View File

@ -1,5 +1,6 @@
import * as Minio from 'minio'
import { randomUUID } from 'crypto'
import { Readable } from 'stream'
import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient'
export interface StoredFile {
@ -20,6 +21,19 @@ export interface FileMetadata {
objectName: string
}
export interface StoredFileStream {
stream: Readable
contentType: string
filename: string
size?: number
}
export interface StoredFileStreamMetadata {
contentType?: string | null
filename?: string | null
size?: number | null
}
// 生成对象名称,使用分层结构优化性能
function generateObjectName(type: 'screenshot' | 'version' | 'other', hostname?: string): string {
const now = new Date()
@ -168,6 +182,27 @@ export async function getFileByObjectName(objectName: string): Promise<{ buffer:
}
}
export async function getFileStreamByObjectName(
objectName: string,
metadata: StoredFileStreamMetadata = {}
): Promise<StoredFileStream | null> {
try {
await ensureMinIOReady()
const stream = await minioClient.getObject(BUCKET_NAME, objectName)
return {
stream,
contentType: metadata.contentType || 'application/octet-stream',
filename: metadata.filename || objectName.split('/').pop() || 'unknown',
size: metadata.size ?? undefined
}
} catch (error) {
console.error('❌ 获取文件流失败:', error)
return null
}
}
export async function deleteFile(objectName: string): Promise<boolean> {
try {
await ensureMinIOReady()

75
lib/geodata.ts Normal file
View File

@ -0,0 +1,75 @@
import IP2Region from 'ip2region'
import { existsSync } from 'fs'
import { join } from 'path'
import { isPrivateIp, normalizeIpCandidate } from './clientIp'
interface IpGeodata {
country: string
province: string
city: string
isp: string
}
const ipLocationLabelCache = new Map<string, string>()
let ip2Region: IP2Region | null | undefined
export function getIpGeodataLabel(ip?: string | null) {
const normalizedIp = normalizeIpCandidate(ip)
if (!normalizedIp) {
return null
}
const cachedLabel = ipLocationLabelCache.get(normalizedIp)
if (cachedLabel) {
return cachedLabel
}
const label = resolveIpGeodataLabel(normalizedIp)
ipLocationLabelCache.set(normalizedIp, label)
return label
}
function resolveIpGeodataLabel(ip: string) {
if (isPrivateIp(ip)) {
return '内网地址'
}
try {
const geodata = getIp2Region()?.search(ip) as IpGeodata | null
const parts = uniqueNonEmptyValues([
geodata?.country,
geodata?.province,
geodata?.city,
geodata?.isp
])
return parts.length > 0 ? parts.join(' ') : '未知归属地'
} catch {
return '未知归属地'
}
}
function getIp2Region() {
if (ip2Region !== undefined) {
return ip2Region
}
const dataDir = join(process.cwd(), 'node_modules', 'ip2region', 'data')
const ipv4db = join(dataDir, 'ip2region.db')
const ipv6db = join(dataDir, 'ipv6wry.db')
if (!existsSync(ipv4db) || !existsSync(ipv6db)) {
console.warn(`[geodata] ip2region database files not found in ${dataDir}`)
ip2Region = null
return ip2Region
}
ip2Region = new IP2Region({ ipv4db, ipv6db })
return ip2Region
}
function uniqueNonEmptyValues(values: Array<string | undefined>) {
return Array.from(new Set(values
.map(value => value?.trim())
.filter((value): value is string => !!value && value !== '0')))
}

View File

@ -26,6 +26,7 @@
"date-fns": "^4.1.0",
"dotenv-cli": "^8.0.0",
"fluent-ffmpeg": "^2.1.3",
"ip2region": "^2.3.0",
"lucide-react": "^0.525.0",
"minio": "^8.0.5",
"multer": "^2.0.1",

View File

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "screenshots_fileId_idx" ON "screenshots"("fileId");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "records" ADD COLUMN "ip" TEXT;

View File

@ -30,6 +30,7 @@ model Host {
model Record {
id String @id @default(cuid())
hostname String
ip String?
timestamp DateTime @default(now())
isStarred Boolean @default(false)
createdAt DateTime @default(now())
@ -75,6 +76,7 @@ model Screenshot {
record Record @relation(fields: [recordId], references: [id], onDelete: Cascade)
@@index([recordId])
@@index([fileId])
@@index([objectName])
@@map("screenshots")
}