fix(ui): resolve dark mode layout gray colors in light-mode browser and fix row index looping on paginated tables
This commit is contained in:
1 parent
46f33eb088
commit
202ecfa485
19 files changed
+374
-34
No files matched your search
@@ -66,3 +66,6 @@ CLAUDE.md
|
||||
docs/
|
||||
plans/
|
||||
.env*
|
||||
|
||||
# Local uploads
|
||||
backend/public/api/uploads/
|
||||
@@ -101,26 +101,45 @@ router.post('/change-account-name', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// ─── POST /api/auth/upload-profile-picture ───────────────────────────────────
|
||||
router.post('/upload-profile-picture', requireAuth, upload.single('profile_picture'), async (req, res) => {
|
||||
// Multer errors TIDAK tertangkap oleh try/catch di route handler.
|
||||
// Wajib menggunakan callback agar error multer dikembalikan sebagai JSON, bukan HTML.
|
||||
router.post('/upload-profile-picture', requireAuth, (req, res, next) => {
|
||||
upload.single('profile_picture')(req, res, (multerErr) => {
|
||||
if (multerErr) {
|
||||
console.error('[Upload] Multer error:', multerErr.message);
|
||||
return res.status(400).json({ error: `Upload gagal: ${multerErr.message}` });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No image uploaded. Please select an image file first.' });
|
||||
|
||||
const user = await User.findById(req.user.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// Hapus foto profil lama jika ada
|
||||
if (user.profile_picture) {
|
||||
const oldPath = path.join(getUploadsDir(), user.profile_picture);
|
||||
if (fs.existsSync(oldPath)) {
|
||||
try { fs.unlinkSync(oldPath); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
user.profile_picture = req.file.filename;
|
||||
await user.save();
|
||||
|
||||
const newToken = makeToken(user);
|
||||
setCookieToken(res, newToken);
|
||||
|
||||
res.json({ ok: true, message: 'Foto profil berhasil diperbarui', profile_picture: req.file.filename });
|
||||
res.json({ ok: true, message: 'Profile picture updated successfully.', profile_picture: req.file.filename });
|
||||
} catch (err) {
|
||||
console.error('[Upload Error]', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── POST /api/auth/remove-profile-picture ───────────────────────────────────
|
||||
router.post('/remove-profile-picture', requireAuth, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -122,6 +122,19 @@ app.get('/api/health', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Global JSON Error Handler ────────────────────────────────────────────────
|
||||
// Menangkap semua error yang tidak di-handle (termasuk multer, mongoose, dll.)
|
||||
// dan memastikan response selalu JSON, BUKAN HTML default Express.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[Global Error Handler]', err.message || err);
|
||||
const status = err.status || err.statusCode || 500;
|
||||
res.status(status).json({
|
||||
error: err.message || 'Internal server error',
|
||||
code: err.code || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Start Server ─────────────────────────────────────────────────────────────
|
||||
// Bind to 127.0.0.1 in production to prevent direct external access to port 3001.
|
||||
// All external traffic must go through the reverse proxy (Apache/Nginx) at port 80/443.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// scripts/upload-profile-pictures.js
|
||||
// Upload semua foto profil dari lokal ke server production
|
||||
'use strict';
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const { Client: SshClient } = require('ssh2');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CONFIG = {
|
||||
host: '103.185.47.52',
|
||||
port: 2222,
|
||||
username: 'adminbackend',
|
||||
password: 'htEo7x6LsBQiEHHH',
|
||||
readyTimeout: 30000,
|
||||
retries: 3,
|
||||
retry_factor: 2,
|
||||
retry_minTimeout: 2000,
|
||||
};
|
||||
const LOCAL_DIR = path.join(__dirname, '..', 'backend', 'public', 'api', 'uploads');
|
||||
const REMOTE_DIR = '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads';
|
||||
|
||||
function sshExec(cmd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SshClient();
|
||||
conn.on('ready', () => {
|
||||
conn.exec(cmd, (err, stream) => {
|
||||
if (err) { conn.end(); return reject(err); }
|
||||
let out = '';
|
||||
stream
|
||||
.on('data', d => { out += d; })
|
||||
.stderr.on('data', d => { out += d; });
|
||||
stream.on('close', () => { conn.end(); resolve(out); });
|
||||
});
|
||||
}).on('error', reject)
|
||||
.connect({ host: CONFIG.host, port: CONFIG.port, username: CONFIG.username, password: CONFIG.password, readyTimeout: CONFIG.readyTimeout });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(LOCAL_DIR)) {
|
||||
console.log('Folder uploads lokal tidak ditemukan:', LOCAL_DIR);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(LOCAL_DIR).filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f));
|
||||
if (files.length === 0) {
|
||||
console.log('Tidak ada file gambar di folder uploads lokal.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Ditemukan ${files.length} file foto profil untuk diupload:\n ${files.join('\n ')}\n`);
|
||||
|
||||
// Buat direktori di server jika belum ada
|
||||
await sshExec(`mkdir -p ${REMOTE_DIR} && chmod 755 ${REMOTE_DIR}`);
|
||||
console.log(`[Server] Direktori ${REMOTE_DIR} siap\n`);
|
||||
|
||||
// Upload semua file via SFTP
|
||||
const sftp = new SftpClient();
|
||||
await sftp.connect(CONFIG);
|
||||
console.log('[SFTP] Terhubung ke server\n');
|
||||
|
||||
let uploaded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const localPath = path.join(LOCAL_DIR, file);
|
||||
const remotePath = `${REMOTE_DIR}/${file}`;
|
||||
try {
|
||||
await sftp.put(localPath, remotePath);
|
||||
console.log(`✅ Upload: ${file}`);
|
||||
uploaded++;
|
||||
} catch (err) {
|
||||
console.error(`❌ Gagal upload ${file}:`, err.message);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
await sftp.end();
|
||||
|
||||
// Verifikasi
|
||||
const remoteList = await sshExec(`ls -lh ${REMOTE_DIR}`);
|
||||
console.log('\n[Server] Isi folder uploads:\n' + remoteList);
|
||||
|
||||
console.log(`\n✅ Selesai! ${uploaded} berhasil, ${failed} gagal.`);
|
||||
}
|
||||
|
||||
main().catch(e => console.error('[FATAL]', e.message));
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { SafeImage } from "@/components/ui/SafeImage";
|
||||
import { type ManagedUser } from "@/lib/admin-api";
|
||||
import { UserCog, CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
@@ -16,7 +17,12 @@ export function getExternalAccountColumns(
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 shrink-0 overflow-hidden">
|
||||
{row.profile_picture ? (
|
||||
<img src={`/api/uploads/${row.profile_picture}`} alt={row.account_name || row.username} className="w-full h-full object-cover" />
|
||||
<SafeImage
|
||||
src={`/api/uploads/${row.profile_picture}`}
|
||||
alt={row.account_name || row.username}
|
||||
fallbackName={row.account_name || row.username}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-slate-400">{(row.account_name || row.username).substring(0, 2).toUpperCase()}</span>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,13 @@ export default function AppsPage() {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? (b.upload ?? 0) - (a.upload ?? 0) : (a.upload ?? 0) - (b.upload ?? 0));
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? (b.download ?? 0) - (a.download ?? 0) : (a.download ?? 0) - (b.download ?? 0));
|
||||
} else {
|
||||
// Default: sort by latest activity (newest first)
|
||||
processedData.sort((a, b) => {
|
||||
const timeA = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||||
const timeB = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
}
|
||||
|
||||
// Use the data retention window (7 days) as the minimum selectable date.
|
||||
|
||||
@@ -148,6 +148,13 @@ export default function DevicesPage() {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
} else {
|
||||
// Default: sort by latest activity (newest first)
|
||||
processedData.sort((a, b) => {
|
||||
const timeA = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||||
const timeB = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
}
|
||||
|
||||
// Use the data retention window (7 days) as the minimum selectable date.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// src/app/api/auth/remove-profile-picture/route.ts
|
||||
// Next.js API Route — proxies remove-profile-picture ke backend Express.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3001";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
const headers: HeadersInit = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Cookie"] = `token=${token}`;
|
||||
}
|
||||
|
||||
const backendRes = await fetch(
|
||||
`${BACKEND_URL}/api/auth/remove-profile-picture`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
const contentType = backendRes.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
const text = await backendRes.text();
|
||||
console.error("[Remove-Picture Proxy] Backend returned non-JSON:", text.slice(0, 200));
|
||||
return NextResponse.json(
|
||||
{ error: `Backend error (HTTP ${backendRes.status}): Mohon coba lagi.` },
|
||||
{ status: backendRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await backendRes.json();
|
||||
const response = NextResponse.json(data, { status: backendRes.status });
|
||||
|
||||
const setCookie = backendRes.headers.get("set-cookie");
|
||||
if (setCookie) {
|
||||
response.headers.set("set-cookie", setCookie);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
console.error("[Remove-Picture Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Gagal terhubung ke backend. Pastikan server berjalan." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// src/app/api/auth/upload-profile-picture/route.ts
|
||||
// Next.js API Route — proxies multipart upload ke backend Express.
|
||||
// Menggantikan next.config.ts rewrite yang tidak reliable untuk multipart/form-data.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3001";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Ambil auth cookie dan teruskan ke backend
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
// Baca form data dari request asli
|
||||
const formData = await req.formData();
|
||||
|
||||
// Buat header — teruskan cookie auth
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers["Cookie"] = `token=${token}`;
|
||||
}
|
||||
|
||||
// Forward ke backend Express
|
||||
const backendRes = await fetch(
|
||||
`${BACKEND_URL}/api/auth/upload-profile-picture`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
const contentType = backendRes.headers.get("content-type") || "";
|
||||
|
||||
// Jika backend mengembalikan HTML (error), beri JSON error yang informatif
|
||||
if (!contentType.includes("application/json")) {
|
||||
const text = await backendRes.text();
|
||||
console.error("[Upload Proxy] Backend returned non-JSON:", text.slice(0, 200));
|
||||
return NextResponse.json(
|
||||
{ error: `Backend error (HTTP ${backendRes.status}): Mohon coba lagi.` },
|
||||
{ status: backendRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await backendRes.json();
|
||||
|
||||
// Teruskan Set-Cookie dari backend (refresh token) ke browser
|
||||
const response = NextResponse.json(data, { status: backendRes.status });
|
||||
const setCookie = backendRes.headers.get("set-cookie");
|
||||
if (setCookie) {
|
||||
response.headers.set("set-cookie", setCookie);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
console.error("[Upload Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Gagal terhubung ke backend. Pastikan server berjalan." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Nonaktifkan body parser bawaan Next.js — biar formData diurus manual
|
||||
export const config = {
|
||||
api: { bodyParser: false },
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function ProfilePictureTab({ user, setUser, onClose }: Props) {
|
||||
<div className="h-24 w-24 rounded-full bg-gradient-to-tr from-primary to-blue-400 p-0.5 shadow-lg shadow-primary/20 mb-4 overflow-hidden relative group">
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-background overflow-hidden relative">
|
||||
{user?.profile_picture ? (
|
||||
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" fallbackName={user.account_name || user.username} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-white uppercase">
|
||||
{user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : 'A')}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden">
|
||||
{user?.profile_picture ? (
|
||||
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" fallbackName={user.account_name || user.username} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold text-white uppercase text-xs">
|
||||
{initial}
|
||||
@@ -70,7 +70,7 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
<div className="flex flex-col items-center justify-center p-3 mb-2 border-b border-border/50">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden mb-2">
|
||||
{user?.profile_picture ? (
|
||||
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" fallbackName={user.account_name || user.username} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold text-white uppercase text-lg">
|
||||
{initial}
|
||||
|
||||
@@ -63,6 +63,14 @@ export default function ThreatsContent() {
|
||||
});
|
||||
}, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]);
|
||||
|
||||
// Default sort: newest detected threats first (for table display)
|
||||
const sortedFiltered = useMemo(() =>
|
||||
[...filtered].sort((a, b) =>
|
||||
new Date(b.detected_at || 0).getTime() - new Date(a.detected_at || 0).getTime()
|
||||
),
|
||||
[filtered]
|
||||
);
|
||||
|
||||
// Automatically select a threat if highlightId is passed
|
||||
useEffect(() => {
|
||||
if (highlightId && all.length > 0) {
|
||||
@@ -206,7 +214,7 @@ export default function ThreatsContent() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={filtered}
|
||||
data={sortedFiltered}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search threats by IP, domain, type..."
|
||||
searchFilter={(row, query) => {
|
||||
|
||||
@@ -108,7 +108,18 @@ export default function ThreatsSummaryPdfDocument({
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
const incidents = threatsData.slice(0, 50);
|
||||
// Sort incidents by severity (Critical > High > Medium > Low > Info) for PDF export
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
critical: 0, high: 1, medium: 2, med: 2, low: 3, info: 4,
|
||||
};
|
||||
const getSeverityRank = (sev: string): number => {
|
||||
const s = (sev || "").toLowerCase();
|
||||
const match = Object.entries(SEVERITY_RANK).find(([k]) => s.includes(k));
|
||||
return match ? match[1] : 5;
|
||||
};
|
||||
const incidents = [...threatsData]
|
||||
.sort((a, b) => getSeverityRank(a.severity) - getSeverityRank(b.severity))
|
||||
.slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
|
||||
@@ -86,14 +86,21 @@ export default function AgentPdfDocument({
|
||||
}: AgentPdfDocumentProps) {
|
||||
const summary = data.summary || {};
|
||||
|
||||
// Compliance check: show all data up to 50 threshold
|
||||
const devices = (data.devices || []).slice(0, 50);
|
||||
const flows = (data.flows || []).slice(0, 50);
|
||||
const topApps = (data.top_apps || []).slice(0, 50);
|
||||
const insecure = (data.security?.insecure_protocols || []).slice(0, 50);
|
||||
const reputation = (data.security?.ip_reputation || []).slice(0, 50);
|
||||
const events = (data.events || []).slice(0, 50);
|
||||
const macBw = (data.mac_bandwidth || []).slice(0, 50);
|
||||
// Sort by total bandwidth (download + upload) descending for PDF export
|
||||
const sortByBandwidth = (arr: any[]) =>
|
||||
[...arr].sort((a, b) =>
|
||||
((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) -
|
||||
((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0))
|
||||
);
|
||||
|
||||
const devices = sortByBandwidth(data.devices || []).slice(0, 50);
|
||||
const flows = sortByBandwidth(data.flows || []).slice(0, 50);
|
||||
const topApps = sortByBandwidth(data.top_apps || []).slice(0, 50);
|
||||
const macBw = sortByBandwidth(data.mac_bandwidth || []).slice(0, 50);
|
||||
// Security alerts: not sorted by bandwidth — keep original order
|
||||
const insecure = (data.security?.insecure_protocols || []).slice(0, 50);
|
||||
const reputation = (data.security?.ip_reputation || []).slice(0, 50);
|
||||
const events = (data.events || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
|
||||
@@ -84,8 +84,14 @@ export default function AppPdfDocument({
|
||||
category,
|
||||
reportDateStr,
|
||||
}: AppPdfDocumentProps) {
|
||||
const topIps = (data.top_ips || []).slice(0, 50);
|
||||
const agents = (data.agent_scorecard || []).slice(0, 50);
|
||||
// Sort by total bandwidth (download + upload) descending for PDF export
|
||||
const sortByBandwidth = (arr: any[]) =>
|
||||
[...arr].sort((a, b) =>
|
||||
((b.download ?? 0) + (b.upload ?? 0)) - ((a.download ?? 0) + (a.upload ?? 0))
|
||||
);
|
||||
|
||||
const topIps = sortByBandwidth(data.top_ips || []).slice(0, 50);
|
||||
const agents = sortByBandwidth(data.agent_scorecard || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
|
||||
@@ -197,7 +197,7 @@ export function DataTable<T>({
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground transition-colors truncate whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
{col.accessor(row, startIndex + i)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -86,12 +86,21 @@ export default function DevicePdfDocument({
|
||||
deviceData,
|
||||
reportDateStr,
|
||||
}: DevicePdfDocumentProps) {
|
||||
// Compliance check: show all data up to 50 threshold
|
||||
const topApps = (data.apps || []).slice(0, 50);
|
||||
const topProtocols = (data.protocols || []).slice(0, 50);
|
||||
const topDomains = (data.domains || []).slice(0, 50);
|
||||
const detectedThreats = (data.threats || []).slice(0, 50);
|
||||
const flows = (data.flows || []).slice(0, 50);
|
||||
// Sort by total bandwidth (download + upload) descending for PDF export
|
||||
const sortByBandwidth = (arr: any[]) =>
|
||||
[...arr].sort((a, b) =>
|
||||
((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) -
|
||||
((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0))
|
||||
);
|
||||
|
||||
const topApps = sortByBandwidth(data.apps || []).slice(0, 50);
|
||||
const topProtocols = sortByBandwidth(data.protocols || []).slice(0, 50);
|
||||
const topDomains = sortByBandwidth(data.domains || []).slice(0, 50);
|
||||
// Threats: sorted by detected_at descending (newest first), not by bandwidth
|
||||
const detectedThreats = [...(data.threats || [])]
|
||||
.sort((a, b) => new Date(b.detected_at || 0).getTime() - new Date(a.detected_at || 0).getTime())
|
||||
.slice(0, 50);
|
||||
const flows = sortByBandwidth(data.flows || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
|
||||
@@ -4,9 +4,30 @@ import React, { useState, useEffect } from "react";
|
||||
|
||||
interface SafeImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
/** Nama pengguna untuk ditampilkan sebagai letter avatar saat gambar gagal dimuat */
|
||||
fallbackName?: string;
|
||||
}
|
||||
|
||||
export function SafeImage({ src, ...props }: SafeImageProps) {
|
||||
/** Hasilkan warna HSL deterministik berdasarkan string nama */
|
||||
function getAvatarColor(name: string): string {
|
||||
if (!name) return "hsl(220, 30%, 25%)";
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return `hsl(${hue}, 45%, 30%)`;
|
||||
}
|
||||
|
||||
/** Ambil 1-2 inisial huruf dari nama */
|
||||
function getInitials(name: string): string {
|
||||
if (!name) return "?";
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function SafeImage({ src, fallbackName, ...props }: SafeImageProps) {
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
@@ -44,18 +65,29 @@ export function SafeImage({ src, ...props }: SafeImageProps) {
|
||||
}, [src]);
|
||||
|
||||
if (error || !imageSrc) {
|
||||
const initials = getInitials(fallbackName || "");
|
||||
const bgColor = getAvatarColor(fallbackName || "");
|
||||
return (
|
||||
<div
|
||||
className={props.className}
|
||||
style={{
|
||||
backgroundColor: "rgba(30, 41, 59, 0.5)",
|
||||
<div
|
||||
className={props.className}
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
justifyContent: "center",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">
|
||||
Profile
|
||||
<span
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
fontWeight: 700,
|
||||
fontSize: "inherit",
|
||||
lineHeight: 1,
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -63,3 +95,4 @@ export function SafeImage({ src, ...props }: SafeImageProps) {
|
||||
|
||||
return <img src={imageSrc} {...props} />;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user