first commit
This commit is contained in:
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
PORT=5880
|
||||||
|
LOGS_DIR=./logs
|
||||||
|
KEYCLOAK_URL=https://auth.rssa.top/
|
||||||
|
KEYCLOAK_REALM=rssa
|
||||||
|
KEYCLOAK_CLIENT_ID=satu
|
||||||
|
KEYCLOAK_CLIENT_SECRET=ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE
|
||||||
|
# URL Aplikasi SIMRS Utama (Kembali ke SIMRS Utama)
|
||||||
|
SIMRS_URL=http://meninjar.dev.rssa.id:4003
|
||||||
|
# Opsional: Jika Keycloak membatasi redirect_uri ke port tertentu (misal 4003 / tanpa port)
|
||||||
|
KEYCLOAK_REDIRECT_URI=
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Core dump files & OS junk
|
||||||
|
core
|
||||||
|
core.*
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# ============================================
|
||||||
|
# SIMRS Log Forensic Inspector — DEVELOPMENT
|
||||||
|
# ============================================
|
||||||
|
# Mode: Hot-reload server via --watch-path
|
||||||
|
# Source: Mounted dari host via docker-compose volume
|
||||||
|
# Frontend: Disajikan dari dist/ yang di-mount dari host
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
FROM node:23-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependency manifest first (cache layer)
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install ALL dependencies (termasuk devDependencies)
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source (akan di-override oleh volume mount saat dev)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build frontend bundle awal
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 5880
|
||||||
|
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
ENV PORT=5880
|
||||||
|
|
||||||
|
# Watch hanya folder server/ agar perubahan log tidak trigger restart
|
||||||
|
CMD ["node", "--watch-path=server", "server/index.js"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# ============================================
|
||||||
|
# SIMRS Log Forensic Inspector — PRODUCTION
|
||||||
|
# ============================================
|
||||||
|
# Mode: Multi-stage build, optimized image
|
||||||
|
# Frontend: Static bundle baked into image
|
||||||
|
# Server: Node.js tanpa devDependencies
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# --- Stage 1: Build frontend ---
|
||||||
|
FROM node:23-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Stage 2: Production runtime ---
|
||||||
|
FROM node:23-alpine AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy built frontend bundle
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Copy server source code only
|
||||||
|
COPY server ./server
|
||||||
|
|
||||||
|
EXPOSE 5880
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=5880
|
||||||
|
|
||||||
|
# Healthcheck: cek endpoint utama
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget --quiet --tries=1 --spider http://localhost:5880/ || exit 1
|
||||||
|
|
||||||
|
# Jalankan server langsung tanpa --watch
|
||||||
|
CMD ["node", "server/index.js"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
.PHONY: help dev-up dev-build prod-up prod-build down logs restart status clean
|
||||||
|
|
||||||
|
APP_NAME = simrs-log-forensic
|
||||||
|
PORT = 5880
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo ""
|
||||||
|
@echo "╔══════════════════════════════════════════════════╗"
|
||||||
|
@echo "║ SIMRS Forensic Inspector (Port $(PORT)) ║"
|
||||||
|
@echo "╠══════════════════════════════════════════════════╣"
|
||||||
|
@echo "║ DEVELOPMENT ║"
|
||||||
|
@echo "║ make dev-up → Jalankan mode dev ║"
|
||||||
|
@echo "║ make dev-build → Build ulang image dev ║"
|
||||||
|
@echo "║ ║"
|
||||||
|
@echo "║ PRODUCTION ║"
|
||||||
|
@echo "║ make prod-up → Jalankan mode production ║"
|
||||||
|
@echo "║ make prod-build → Build ulang image prod ║"
|
||||||
|
@echo "║ ║"
|
||||||
|
@echo "║ UMUM ║"
|
||||||
|
@echo "║ make down → Hentikan & hapus ║"
|
||||||
|
@echo "║ make logs → Lihat log real-time ║"
|
||||||
|
@echo "║ make restart → Restart container ║"
|
||||||
|
@echo "║ make status → Cek status container ║"
|
||||||
|
@echo "║ make clean → Hapus node_modules & dist ║"
|
||||||
|
@echo "╚══════════════════════════════════════════════════╝"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
# ─── Development ──────────────────────────────
|
||||||
|
dev-up:
|
||||||
|
docker compose --profile dev up -d
|
||||||
|
|
||||||
|
dev-build:
|
||||||
|
docker compose --profile dev up --build --force-recreate -d
|
||||||
|
|
||||||
|
# ─── Production ───────────────────────────────
|
||||||
|
prod-up:
|
||||||
|
docker compose --profile prod up -d
|
||||||
|
|
||||||
|
prod-build:
|
||||||
|
docker compose --profile prod up --build --force-recreate -d
|
||||||
|
|
||||||
|
# ─── Umum ─────────────────────────────────────
|
||||||
|
down:
|
||||||
|
docker compose --profile dev --profile prod down -v
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose --profile dev --profile prod logs -f
|
||||||
|
|
||||||
|
restart:
|
||||||
|
docker compose --profile dev --profile prod restart
|
||||||
|
|
||||||
|
status:
|
||||||
|
docker compose --profile dev --profile prod ps
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf node_modules dist
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ============================================
|
||||||
|
# SIMRS Log Forensic Inspector
|
||||||
|
# docker-compose.yml
|
||||||
|
# ============================================
|
||||||
|
# Gunakan:
|
||||||
|
# make dev-up → Jalankan mode DEVELOPMENT
|
||||||
|
# make prod-up → Jalankan mode PRODUCTION
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
x-common: &common
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5880:5880"
|
||||||
|
environment: &common-env
|
||||||
|
PORT: 5880
|
||||||
|
LOGS_DIR: /app/logs
|
||||||
|
KEYCLOAK_URL: https://auth.rssa.top/
|
||||||
|
KEYCLOAK_REALM: rssa
|
||||||
|
KEYCLOAK_CLIENT_ID: satu
|
||||||
|
KEYCLOAK_CLIENT_SECRET: ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE
|
||||||
|
SIMRS_URL: http://meninjar.dev.rssa.id:4003
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─── Development ───────────────────────────
|
||||||
|
dev:
|
||||||
|
<<: *common
|
||||||
|
container_name: simrs-log-forensic-dev
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
profiles: ["dev"]
|
||||||
|
environment:
|
||||||
|
<<: *common-env
|
||||||
|
NODE_ENV: development
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /mnt/simrs/logs:/app/logs
|
||||||
|
|
||||||
|
# ─── Production ────────────────────────────
|
||||||
|
prod:
|
||||||
|
<<: *common
|
||||||
|
container_name: simrs-log-forensic
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.prod
|
||||||
|
profiles: ["prod"]
|
||||||
|
environment:
|
||||||
|
<<: *common-env
|
||||||
|
NODE_ENV: production
|
||||||
|
volumes:
|
||||||
|
- /mnt/simrs/logs:/app/logs
|
||||||
File diff suppressed because it is too large
Load diff
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SIMRS Logs Inspector — Standalone Dashboard</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100 font-sans antialiased selection:bg-cyan-500 selection:text-white min-h-screen">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3667
File diff suppressed because it is too large
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "log-forensic",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=23.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "node server/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"sweetalert2": "^11.26.25"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.5",
|
||||||
|
"@types/adm-zip": "^0.5.5",
|
||||||
|
"@types/cookie-parser": "^1.4.8",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.13.1",
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"tailwindcss": "^4.0.5",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+687
@@ -0,0 +1,687 @@
|
|||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import http from "http";
|
||||||
|
import axios from "axios";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import {
|
||||||
|
scanLogs,
|
||||||
|
getBruteForceLocks,
|
||||||
|
unblockIp,
|
||||||
|
getBackupArchives,
|
||||||
|
} from "./logParser.js";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 5880;
|
||||||
|
const LOGS_DIR = path.resolve(process.env.LOGS_DIR || "./logs");
|
||||||
|
const isDev =
|
||||||
|
process.env.NODE_ENV !== "production" || process.env.VITE_DEV === "true";
|
||||||
|
|
||||||
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Default Keycloak Env Config
|
||||||
|
const defaultKcServer = process.env.KEYCLOAK_URL || "https://auth.rssa.top/";
|
||||||
|
const defaultKcRealm = process.env.KEYCLOAK_REALM || "rssa";
|
||||||
|
const defaultKcClient = process.env.KEYCLOAK_CLIENT_ID || "satu";
|
||||||
|
const defaultKcSecret =
|
||||||
|
process.env.KEYCLOAK_CLIENT_SECRET || "ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE";
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "forensic_session";
|
||||||
|
const PKCE_STATE_COOKIE = "pkce_auth_state";
|
||||||
|
|
||||||
|
// Helper for Session cookie
|
||||||
|
function getSessionUser(req) {
|
||||||
|
const sessionStr = req.cookies?.[SESSION_COOKIE];
|
||||||
|
if (!sessionStr) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(sessionStr, "base64").toString("utf-8"));
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionUser(res, user) {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(user)).toString("base64");
|
||||||
|
res.cookie(SESSION_COOKIE, encoded, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 86400 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cryptographic Helpers for PKCE
|
||||||
|
function base64UrlEncode(buffer) {
|
||||||
|
return buffer
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCodeVerifier() {
|
||||||
|
return base64UrlEncode(crypto.randomBytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCodeChallenge(verifier) {
|
||||||
|
const hash = crypto.createHash("sha256").update(verifier).digest();
|
||||||
|
return base64UrlEncode(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keycloak Group Verification Helper (Restricted to "Instalasi STIM" / "STIM")
|
||||||
|
async function extractAndVerifyUserGroups(payload, accessToken, serverUrl, realm) {
|
||||||
|
const groups = new Set();
|
||||||
|
|
||||||
|
const checkClaim = (val) => {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
val.forEach((item) => {
|
||||||
|
if (typeof item === "string") groups.add(item);
|
||||||
|
else if (item && item.name) groups.add(item.name);
|
||||||
|
});
|
||||||
|
} else if (typeof val === "string") {
|
||||||
|
groups.add(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkClaim(payload.groups);
|
||||||
|
checkClaim(payload.group);
|
||||||
|
checkClaim(payload.realm_access?.roles);
|
||||||
|
if (payload.resource_access) {
|
||||||
|
Object.values(payload.resource_access).forEach((resAcc) => {
|
||||||
|
checkClaim(resAcc?.roles);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no groups found in token payload, query Keycloak UserInfo Endpoint
|
||||||
|
if (accessToken && serverUrl && realm) {
|
||||||
|
try {
|
||||||
|
const cleanServer = serverUrl.replace(/\/$/, "");
|
||||||
|
const userinfoUrl = `${cleanServer}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/userinfo`;
|
||||||
|
const res = await axios.get(userinfoUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
if (res.data) {
|
||||||
|
checkClaim(res.data.groups);
|
||||||
|
checkClaim(res.data.group);
|
||||||
|
checkClaim(res.data.realm_access?.roles);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Keycloak UserInfo fetch warning:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupList = Array.from(groups);
|
||||||
|
console.log("Extracted Keycloak user groups:", groupList);
|
||||||
|
|
||||||
|
// Group STIM Authorization check
|
||||||
|
const isAllowed = groupList.some((g) => {
|
||||||
|
const lower = String(g).toLowerCase();
|
||||||
|
return lower.includes("stim") || lower.includes("instalasi stim");
|
||||||
|
});
|
||||||
|
|
||||||
|
return { isAllowed, groupList };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(LOGS_DIR)) {
|
||||||
|
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// REST API ENDPOINTS
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
// 1. PKCE Keycloak SSO Initiate Route
|
||||||
|
app.get("/api/auth/pkce/login", (req, res) => {
|
||||||
|
const serverUrl = (req.query.kc_server || defaultKcServer)
|
||||||
|
.toString()
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
const realm = (req.query.kc_realm || defaultKcRealm).toString();
|
||||||
|
const clientId = (req.query.kc_client_id || defaultKcClient).toString();
|
||||||
|
const clientSecret = (
|
||||||
|
req.query.kc_client_secret || defaultKcSecret
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||||
|
const state = base64UrlEncode(crypto.randomBytes(16));
|
||||||
|
|
||||||
|
const proto = req.headers["x-forwarded-proto"] || req.protocol || "http";
|
||||||
|
const host = req.headers["x-forwarded-host"] || req.headers.host;
|
||||||
|
|
||||||
|
let redirectUri = (
|
||||||
|
req.query.kc_redirect_uri ||
|
||||||
|
process.env.KEYCLOAK_REDIRECT_URI ||
|
||||||
|
""
|
||||||
|
).toString();
|
||||||
|
if (!redirectUri) {
|
||||||
|
redirectUri = `${proto}://${host}/api/auth/pkce/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkceState = {
|
||||||
|
codeVerifier,
|
||||||
|
state,
|
||||||
|
redirectUri,
|
||||||
|
serverUrl,
|
||||||
|
realm,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.cookie(
|
||||||
|
PKCE_STATE_COOKIE,
|
||||||
|
Buffer.from(JSON.stringify(pkceState)).toString("base64"),
|
||||||
|
{
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 600 * 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const authEndpoint = `${serverUrl}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/auth`;
|
||||||
|
const authUrl =
|
||||||
|
`${authEndpoint}?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: "openid profile email",
|
||||||
|
state: state,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
return res.redirect(authUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. PKCE Keycloak SSO Callback Route
|
||||||
|
app.get("/api/auth/pkce/callback", async (req, res) => {
|
||||||
|
const { code, state, error, error_description } = req.query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
res.clearCookie(PKCE_STATE_COOKIE);
|
||||||
|
return res.redirect(
|
||||||
|
`/?error=${encodeURIComponent(error_description || error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkceCookie = req.cookies?.[PKCE_STATE_COOKIE];
|
||||||
|
if (!pkceCookie || !code || !state) {
|
||||||
|
res.clearCookie(PKCE_STATE_COOKIE);
|
||||||
|
return res.redirect("/?error=Sesi+PKCE+tidak+valid+atau+kadaluarsa.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let pkceState = null;
|
||||||
|
try {
|
||||||
|
pkceState = JSON.parse(Buffer.from(pkceCookie, "base64").toString("utf-8"));
|
||||||
|
} catch (e) {
|
||||||
|
res.clearCookie(PKCE_STATE_COOKIE);
|
||||||
|
return res.redirect("/?error=Gagal+membaca+state+PKCE.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pkceState.state !== state) {
|
||||||
|
res.clearCookie(PKCE_STATE_COOKIE);
|
||||||
|
return res.redirect("/?error=Validasi+CSRF+State+PKCE+Gagal.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenEndpoint = `${pkceState.serverUrl}/realms/${encodeURIComponent(pkceState.realm)}/protocol/openid-connect/token`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("grant_type", "authorization_code");
|
||||||
|
params.append("client_id", pkceState.clientId);
|
||||||
|
params.append("code", code.toString());
|
||||||
|
params.append("redirect_uri", pkceState.redirectUri);
|
||||||
|
params.append("code_verifier", pkceState.codeVerifier);
|
||||||
|
if (pkceState.clientSecret) {
|
||||||
|
params.append("client_secret", pkceState.clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenRes = await axios.post(tokenEndpoint, params.toString(), {
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tokenRes.data && tokenRes.data.access_token) {
|
||||||
|
let payload = {};
|
||||||
|
const parts = tokenRes.data.access_token.split(".");
|
||||||
|
if (parts[1]) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(
|
||||||
|
Buffer.from(parts[1], "base64").toString("utf-8"),
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = payload.preferred_username || payload.sub || "User";
|
||||||
|
|
||||||
|
// Verify STIM Group Access
|
||||||
|
const { isAllowed, groupList } = await extractAndVerifyUserGroups(
|
||||||
|
payload,
|
||||||
|
tokenRes.data.access_token,
|
||||||
|
pkceState.serverUrl,
|
||||||
|
pkceState.realm,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
res.clearCookie(PKCE_STATE_COOKIE);
|
||||||
|
const groupInfo = groupList.length > 0 ? groupList.join(", ") : "Tanpa Grup";
|
||||||
|
return res.redirect(
|
||||||
|
`/?error=${encodeURIComponent(`Akses Ditolak: User "${username}" (${groupInfo}) tidak memiliki akses grup Instalasi STIM.`)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
username: username,
|
||||||
|
name: payload.name || username,
|
||||||
|
email: payload.email || "",
|
||||||
|
login_time: new Date().toISOString().replace("T", " ").substring(0, 19),
|
||||||
|
access_token: tokenRes.data.access_token,
|
||||||
|
auth_type: "keycloak_sso_pkce",
|
||||||
|
kc_server: pkceState.serverUrl,
|
||||||
|
kc_realm: pkceState.realm,
|
||||||
|
groups: groupList,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSessionUser(res, user);
|
||||||
|
res.clearCookie(PKCE_STATE_COOKIE);
|
||||||
|
return res.redirect("/");
|
||||||
|
} else {
|
||||||
|
res.clearCookie(PKCE_STATE_COOKIE);
|
||||||
|
return res.redirect("/?error=Penukaran+Token+PKCE+Gagal.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg =
|
||||||
|
err.response?.data?.error_description ||
|
||||||
|
err.response?.data?.error ||
|
||||||
|
err.message;
|
||||||
|
res.clearCookie(PKCE_STATE_COOKIE);
|
||||||
|
return res.redirect(
|
||||||
|
`/?error=Penukaran+Token+PKCE+Error:+${encodeURIComponent(errMsg)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Direct Login Endpoint (Fallback / Temporary Local)
|
||||||
|
app.post("/api/auth/login", async (req, res) => {
|
||||||
|
const {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
kc_server,
|
||||||
|
kc_realm,
|
||||||
|
kc_client_id,
|
||||||
|
kc_client_secret,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Username dan Password wajib diisi." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A. Temporary Local Login
|
||||||
|
if (
|
||||||
|
(username === "[email protected]" || username === "stim") &&
|
||||||
|
password === "RSSAjaya2026"
|
||||||
|
) {
|
||||||
|
const user = {
|
||||||
|
username: "[email protected]",
|
||||||
|
name: "STIM RSSA Admin (Pengembangan SSO)",
|
||||||
|
email: "[email protected]",
|
||||||
|
login_time: new Date().toISOString().replace("T", " ").substring(0, 19),
|
||||||
|
auth_type: "temporary_local",
|
||||||
|
kc_server: "local",
|
||||||
|
kc_realm: "simrs",
|
||||||
|
groups: ["Instalasi STIM"],
|
||||||
|
};
|
||||||
|
setSessionUser(res, user);
|
||||||
|
return res.json({ success: true, user });
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. Keycloak Direct SSO Login (Fall-back Direct Token)
|
||||||
|
const serverUrl = (kc_server || defaultKcServer).replace(/\/$/, "");
|
||||||
|
const realm = kc_realm || defaultKcRealm;
|
||||||
|
const clientId = kc_client_id || defaultKcClient;
|
||||||
|
const clientSecret = kc_client_secret || defaultKcSecret;
|
||||||
|
|
||||||
|
let tokenEndpoint = "";
|
||||||
|
if (serverUrl.includes("/protocol/openid-connect")) {
|
||||||
|
tokenEndpoint = serverUrl;
|
||||||
|
} else if (serverUrl.includes("/realms/")) {
|
||||||
|
tokenEndpoint = `${serverUrl}/protocol/openid-connect/token`;
|
||||||
|
} else {
|
||||||
|
tokenEndpoint = `${serverUrl}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/token`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("grant_type", "password");
|
||||||
|
params.append("client_id", clientId);
|
||||||
|
params.append("username", username);
|
||||||
|
params.append("password", password);
|
||||||
|
params.append("scope", "openid profile email");
|
||||||
|
if (clientSecret) {
|
||||||
|
params.append("client_secret", clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(tokenEndpoint, params.toString(), {
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.access_token) {
|
||||||
|
let payload = {};
|
||||||
|
const parts = response.data.access_token.split(".");
|
||||||
|
if (parts[1]) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(
|
||||||
|
Buffer.from(parts[1], "base64").toString("utf-8"),
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userUsername = payload.preferred_username || username;
|
||||||
|
|
||||||
|
// Verify STIM Group Access
|
||||||
|
const { isAllowed, groupList } = await extractAndVerifyUserGroups(
|
||||||
|
payload,
|
||||||
|
response.data.access_token,
|
||||||
|
serverUrl,
|
||||||
|
realm,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
const groupInfo = groupList.length > 0 ? groupList.join(", ") : "Tanpa Grup";
|
||||||
|
return res.status(403).json({
|
||||||
|
error: `Akses Ditolak: User "${userUsername}" (${groupInfo}) tidak memiliki akses grup Instalasi STIM.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
username: userUsername,
|
||||||
|
name: payload.name || payload.preferred_username || username,
|
||||||
|
email: payload.email || "",
|
||||||
|
login_time: new Date().toISOString().replace("T", " ").substring(0, 19),
|
||||||
|
access_token: response.data.access_token,
|
||||||
|
auth_type: "keycloak_sso",
|
||||||
|
kc_server: serverUrl,
|
||||||
|
kc_realm: realm,
|
||||||
|
groups: groupList,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSessionUser(res, user);
|
||||||
|
return res.json({ success: true, user });
|
||||||
|
} else {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "Keycloak SSO: Token tidak valid." });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg =
|
||||||
|
err.response?.data?.error_description ||
|
||||||
|
err.response?.data?.error ||
|
||||||
|
err.message;
|
||||||
|
return res.status(401).json({ error: `Login Keycloak Gagal: ${errMsg}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", (req, res) => {
|
||||||
|
res.clearCookie(SESSION_COOKIE);
|
||||||
|
res.clearCookie(PKCE_STATE_COOKIE);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/auth/me", (req, res) => {
|
||||||
|
const envKcServer = process.env.KEYCLOAK_URL || process.env.SSO_URL;
|
||||||
|
const envKcRealm = process.env.KEYCLOAK_REALM;
|
||||||
|
const envKcClient = process.env.KEYCLOAK_CLIENT_ID;
|
||||||
|
const isKcEnvConfigured = Boolean(envKcServer || (envKcRealm && envKcClient));
|
||||||
|
const simrsUrl = process.env.SIMRS_URL || "/";
|
||||||
|
|
||||||
|
const user = getSessionUser(req);
|
||||||
|
const config = {
|
||||||
|
isKcEnvConfigured,
|
||||||
|
defaultKcServer,
|
||||||
|
defaultKcRealm,
|
||||||
|
defaultKcClient,
|
||||||
|
defaultKcSecret,
|
||||||
|
simrsUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.json({ authenticated: false, config });
|
||||||
|
}
|
||||||
|
return res.json({ authenticated: true, user, config });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Scan Logs API
|
||||||
|
app.get("/api/logs", (req, res) => {
|
||||||
|
const {
|
||||||
|
source = "auto",
|
||||||
|
date = new Date().toISOString().substring(0, 10),
|
||||||
|
channel = "all",
|
||||||
|
level = "all",
|
||||||
|
time_start = "",
|
||||||
|
time_end = "",
|
||||||
|
search = "",
|
||||||
|
page = "1",
|
||||||
|
limit = "100",
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const filterOpts = {
|
||||||
|
source: String(source),
|
||||||
|
date: String(date),
|
||||||
|
channel: String(channel),
|
||||||
|
level: String(level),
|
||||||
|
timeStart: String(time_start),
|
||||||
|
timeEnd: String(time_end),
|
||||||
|
search: String(search),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { logs, stats } = scanLogs(LOGS_DIR, filterOpts);
|
||||||
|
const lockedIPs = getBruteForceLocks(LOGS_DIR);
|
||||||
|
const backups = getBackupArchives(LOGS_DIR);
|
||||||
|
|
||||||
|
const totalLogs = stats.total;
|
||||||
|
const perPage = parseInt(String(limit), 10) || 100;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(logs.length / perPage));
|
||||||
|
const currentPage = Math.min(
|
||||||
|
Math.max(1, parseInt(String(page), 10) || 1),
|
||||||
|
totalPages,
|
||||||
|
);
|
||||||
|
const offset = (currentPage - 1) * perPage;
|
||||||
|
|
||||||
|
const pagedLogs = logs.slice(offset, offset + perPage);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
logs: pagedLogs,
|
||||||
|
stats: {
|
||||||
|
...stats,
|
||||||
|
brute_force_locked: lockedIPs.length,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
totalLogs,
|
||||||
|
perPage,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
hasNext: currentPage < totalPages,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
backups,
|
||||||
|
lockedIPs,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Brute Force API
|
||||||
|
app.get("/api/brute-force", (req, res) => {
|
||||||
|
const lockedIPs = getBruteForceLocks(LOGS_DIR);
|
||||||
|
res.json({ lockedIPs });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/brute-force/unblock", (req, res) => {
|
||||||
|
const { target_ip } = req.body;
|
||||||
|
if (!target_ip) {
|
||||||
|
return res.status(400).json({ error: "target_ip wajib diisi." });
|
||||||
|
}
|
||||||
|
const success = unblockIp(LOGS_DIR, target_ip);
|
||||||
|
if (success) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Lockout IP ${target_ip} berhasil dibersihkan.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res
|
||||||
|
.status(404)
|
||||||
|
.json({ error: `IP ${target_ip} tidak ditemukan dalam cache lockout.` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Manual Retention Trigger API
|
||||||
|
app.post("/api/retention/run", (req, res) => {
|
||||||
|
const retentionPath = path.join(LOGS_DIR, "retention.log");
|
||||||
|
const nowStr = new Date().toISOString().replace("T", " ").substring(0, 19);
|
||||||
|
const logEntry = `[${nowStr}] [INFO] [system] Manual retention job executed via API.\n`;
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(retentionPath, logEntry);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"Job retention & backup berhasil dijalankan (Manual Retention Triggered).",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: `Gagal menjalankan retention job: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Backups Listing API
|
||||||
|
app.get("/api/backups", (req, res) => {
|
||||||
|
const backups = getBackupArchives(LOGS_DIR);
|
||||||
|
res.json({ backups });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. Export API
|
||||||
|
app.get("/api/export", (req, res) => {
|
||||||
|
const {
|
||||||
|
type = "json",
|
||||||
|
source = "auto",
|
||||||
|
date = new Date().toISOString().substring(0, 10),
|
||||||
|
channel = "all",
|
||||||
|
level = "all",
|
||||||
|
search = "",
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const filterOpts = {
|
||||||
|
source: String(source),
|
||||||
|
date: String(date),
|
||||||
|
channel: String(channel),
|
||||||
|
level: String(level),
|
||||||
|
search: String(search),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { logs } = scanLogs(LOGS_DIR, filterOpts);
|
||||||
|
|
||||||
|
if (type === "csv") {
|
||||||
|
res.setHeader("Content-Type", "text/csv");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="forensic_log_${date}.csv"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let csv = "Timestamp,Correlation ID,Channel,Level,Message,IP,NIP,URI\n";
|
||||||
|
for (const l of logs) {
|
||||||
|
const ts = `"${(l.timestamp || "").replace(/"/g, '""')}"`;
|
||||||
|
const cid = `"${(l.correlation_id || "").replace(/"/g, '""')}"`;
|
||||||
|
const ch = `"${(l.channel || "").replace(/"/g, '""')}"`;
|
||||||
|
const lvl = `"${(l.level || "").replace(/"/g, '""')}"`;
|
||||||
|
const msg = `"${(l.message || "").replace(/"/g, '""')}"`;
|
||||||
|
const ip = `"${(l.context?.ip_address || "").replace(/"/g, '""')}"`;
|
||||||
|
const nip = `"${(l.context?.user_nip || "").replace(/"/g, '""')}"`;
|
||||||
|
const uri = `"${(l.context?.request_uri || "").replace(/"/g, '""')}"`;
|
||||||
|
csv += `${ts},${cid},${ch},${lvl},${msg},${ip},${nip},${uri}\n`;
|
||||||
|
}
|
||||||
|
return res.send(csv);
|
||||||
|
} else {
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="forensic_log_${date}.json"`,
|
||||||
|
);
|
||||||
|
return res.json(logs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// STATIC DIST SERVING (PRIMARY) VS VITE HMR DEV MIDDLEWARE
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
const distDir = path.resolve(__dirname, "../dist");
|
||||||
|
|
||||||
|
if (fs.existsSync(distDir) && process.env.VITE_DEV !== "true") {
|
||||||
|
app.use(express.static(distDir));
|
||||||
|
app.get("*", (req, res, next) => {
|
||||||
|
if (req.path.startsWith("/api")) return next();
|
||||||
|
res.sendFile(path.join(distDir, "index.html"));
|
||||||
|
});
|
||||||
|
} else if (isDev) {
|
||||||
|
try {
|
||||||
|
const { createServer: createViteServer } = await import("vite");
|
||||||
|
const vite = await createViteServer({
|
||||||
|
server: {
|
||||||
|
middlewareMode: true,
|
||||||
|
allowedHosts: true,
|
||||||
|
hmr: {
|
||||||
|
server: server,
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
interval: 500,
|
||||||
|
ignored: ["**/logs/**", "**/node_modules/**", "**/dist/**", "**/.git/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appType: "custom",
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(vite.middlewares);
|
||||||
|
|
||||||
|
app.get("*", async (req, res, next) => {
|
||||||
|
if (req.path.startsWith("/api")) return next();
|
||||||
|
try {
|
||||||
|
const url = req.originalUrl;
|
||||||
|
const indexPath = path.resolve(__dirname, "../index.html");
|
||||||
|
let template = fs.readFileSync(indexPath, "utf-8");
|
||||||
|
template = await vite.transformIndexHtml(url, template);
|
||||||
|
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||||
|
} catch (e) {
|
||||||
|
vite.ssrFixStacktrace(e);
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
"Failed to start Vite Dev Middleware, falling back to static:",
|
||||||
|
err.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.listen(PORT, "0.0.0.0", () => {
|
||||||
|
console.log(
|
||||||
|
`⚡ SIMRS Forensic Inspector Server running on http://0.0.0.0:${PORT} [ENV: ${process.env.NODE_ENV || "development"}]`,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
|
||||||
|
// Pre-compiled regex instances for ultra-fast matching
|
||||||
|
const IP_REGEX = /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/;
|
||||||
|
const NIP_REGEX = /(?:NIP|User|Petugas|Akun|oleh)[:\s]+([A-Za-z0-9_\-\.\,\@\s]{3,40})/i;
|
||||||
|
const URI_REGEX = /\s(\/[a-zA-Z0-9_\-\/\.]+\.php[^\s"']*)/;
|
||||||
|
const URL_REGEX = /https?:\/\/[^\s"']+/;
|
||||||
|
|
||||||
|
export function extractLogMetaData(entry, rawLine) {
|
||||||
|
if (!entry.context || typeof entry.context !== 'object') {
|
||||||
|
entry.context = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: if metadata is already populated, skip scanning
|
||||||
|
const hasIp = Boolean(entry.context.ip_address || entry.context.ip || entry.ip_address);
|
||||||
|
const hasNip = Boolean(entry.context.user_nip || entry.context.user || entry.context.nip || entry.context.username);
|
||||||
|
const hasUri = Boolean(entry.context.request_uri || entry.context.uri || entry.context.url);
|
||||||
|
|
||||||
|
if (hasIp && hasNip && hasUri) return;
|
||||||
|
|
||||||
|
const msg = entry.message || rawLine;
|
||||||
|
|
||||||
|
if (!hasIp) {
|
||||||
|
const ipMatch = rawLine.match(IP_REGEX) || msg.match(IP_REGEX);
|
||||||
|
if (ipMatch && ipMatch[0] !== '127.0.0.1' && ipMatch[0] !== '0.0.0.0') {
|
||||||
|
entry.context.ip_address = ipMatch[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNip) {
|
||||||
|
const mNip = rawLine.match(NIP_REGEX) || msg.match(NIP_REGEX);
|
||||||
|
if (mNip) {
|
||||||
|
entry.context.user_nip = mNip[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUri) {
|
||||||
|
const mUri = rawLine.match(URI_REGEX) || msg.match(URI_REGEX) || rawLine.match(URL_REGEX) || msg.match(URL_REGEX);
|
||||||
|
if (mUri) {
|
||||||
|
entry.context.request_uri = mUri[1] || mUri[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLogLine(line, sourceChannel, filterOpts, lineSeq) {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line) return null;
|
||||||
|
|
||||||
|
let entry = null;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(line);
|
||||||
|
} catch (e) {
|
||||||
|
// Non-JSON line parsing
|
||||||
|
const defaultTs = filterOpts.date ? `${filterOpts.date} 00:00:00` : new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||||
|
let timestamp = '';
|
||||||
|
|
||||||
|
const m1 = line.match(/\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]/);
|
||||||
|
const m2 = line.match(/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)/);
|
||||||
|
const m3 = line.match(/(\d{2}-\d{2}-\d{4}[ T]\d{2}:\d{2}:\d{2})/);
|
||||||
|
|
||||||
|
if (m1) timestamp = m1[1].replace('T', ' ');
|
||||||
|
else if (m2) timestamp = m2[1].replace('T', ' ');
|
||||||
|
else if (m3) {
|
||||||
|
const parts = m3[1].split(/[ T]/);
|
||||||
|
const dParts = parts[0].split('-');
|
||||||
|
timestamp = `${dParts[2]}-${dParts[1]}-${dParts[0]} ${parts[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timestamp) timestamp = defaultTs;
|
||||||
|
else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(timestamp)) {
|
||||||
|
timestamp = timestamp.substring(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
timestamp,
|
||||||
|
channel: sourceChannel || 'access',
|
||||||
|
level: 'INFO',
|
||||||
|
message: line,
|
||||||
|
correlation_id: '-',
|
||||||
|
context: { raw_log: line },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry && !entry.timestamp) {
|
||||||
|
const tsVal = entry.created_at || entry.date || entry.time;
|
||||||
|
if (tsVal) {
|
||||||
|
entry.timestamp = new Date(tsVal).toISOString().replace('T', ' ').substring(0, 19);
|
||||||
|
} else {
|
||||||
|
const m = line.match(/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/);
|
||||||
|
entry.timestamp = m ? m[1].replace('T', ' ') : (filterOpts.date ? `${filterOpts.date} 00:00:00` : new Date().toISOString().replace('T', ' ').substring(0, 19));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry._seq = lineSeq;
|
||||||
|
extractLogMetaData(entry, line);
|
||||||
|
|
||||||
|
const ch = entry.channel || sourceChannel;
|
||||||
|
if (filterOpts.channel && filterOpts.channel !== 'all' && ch.toLowerCase() !== filterOpts.channel.toLowerCase()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lvl = (entry.level || 'INFO').toUpperCase();
|
||||||
|
if (filterOpts.level && filterOpts.level !== 'all' && lvl !== filterOpts.level.toUpperCase()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterOpts.timeStart || filterOpts.timeEnd) {
|
||||||
|
let entryTime = '';
|
||||||
|
const tmMatch = entry.timestamp.match(/[T ](\d{2}:\d{2})/);
|
||||||
|
if (tmMatch) entryTime = tmMatch[1];
|
||||||
|
if (filterOpts.timeStart && entryTime && entryTime < filterOpts.timeStart) return null;
|
||||||
|
if (filterOpts.timeEnd && entryTime && entryTime > filterOpts.timeEnd) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterOpts.search) {
|
||||||
|
const searchLow = filterOpts.search.toLowerCase();
|
||||||
|
const msgLow = (entry.message || '').toLowerCase();
|
||||||
|
const chLow = (entry.channel || '').toLowerCase();
|
||||||
|
const lvlLow = (entry.level || '').toLowerCase();
|
||||||
|
const ipLow = (entry.context?.ip_address || '').toLowerCase();
|
||||||
|
const nipLow = (entry.context?.user_nip || '').toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!msgLow.includes(searchLow) &&
|
||||||
|
!chLow.includes(searchLow) &&
|
||||||
|
!lvlLow.includes(searchLow) &&
|
||||||
|
!ipLow.includes(searchLow) &&
|
||||||
|
!nipLow.includes(searchLow)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanLogs(logsRootDir, filterOpts) {
|
||||||
|
const filterDate = filterOpts.date || new Date().toISOString().substring(0, 10);
|
||||||
|
const [year, month, day] = filterDate.split('-');
|
||||||
|
const dmY = `${day}-${month}-${year}`;
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
const MAX_BROWSER_ITEMS = 100000; // Limit for browser payload only, not scanning
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: 0,
|
||||||
|
login_success: 0,
|
||||||
|
login_failed: 0,
|
||||||
|
session_expired: 0,
|
||||||
|
errors: 0,
|
||||||
|
warnings: 0,
|
||||||
|
performance_count: 0,
|
||||||
|
avg_response_ms: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalRespMs = 0;
|
||||||
|
let respCount = 0;
|
||||||
|
let lineSeq = 0;
|
||||||
|
|
||||||
|
const processLine = (line, ch) => {
|
||||||
|
|
||||||
|
lineSeq++;
|
||||||
|
const item = parseLogLine(line, ch, filterOpts, lineSeq);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
stats.total++;
|
||||||
|
const lvl = (item.level || 'INFO').toUpperCase();
|
||||||
|
if (lvl === 'ERROR' || lvl === 'CRITICAL') stats.errors++;
|
||||||
|
if (lvl === 'WARNING') stats.warnings++;
|
||||||
|
|
||||||
|
const msg = item.message || '';
|
||||||
|
if (/login_success|login ok|login berhasil/i.test(msg)) stats.login_success++;
|
||||||
|
if (/login_failed|gagal login/i.test(msg)) stats.login_failed++;
|
||||||
|
if (/session_expired|session habis/i.test(msg)) stats.session_expired++;
|
||||||
|
|
||||||
|
if ((item.channel || ch).toLowerCase() === 'performance') {
|
||||||
|
stats.performance_count++;
|
||||||
|
if (item.context?.total_duration_ms) {
|
||||||
|
totalRespMs += Number(item.context.total_duration_ms);
|
||||||
|
respCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.push(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterSource = filterOpts.source || 'auto';
|
||||||
|
|
||||||
|
// 1. Forensic Logs
|
||||||
|
if (filterSource === 'auto' || filterSource === 'active') {
|
||||||
|
const forensicDir = path.join(logsRootDir, 'forensic', year, month);
|
||||||
|
if (fs.existsSync(forensicDir)) {
|
||||||
|
const files = fs.readdirSync(forensicDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.log') && file.includes(filterDate)) {
|
||||||
|
const chName = file.split('_')[0] || 'forensic';
|
||||||
|
const content = fs.readFileSync(path.join(forensicDir, file), 'utf-8');
|
||||||
|
content.split(/\r?\n/).forEach((l) => processLine(l, chName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Activity Logs
|
||||||
|
if (filterSource === 'auto' || filterSource === 'active') {
|
||||||
|
const activityPath = path.join(logsRootDir, 'activity', year, month, `${dmY}.log`);
|
||||||
|
if (fs.existsSync(activityPath)) {
|
||||||
|
const content = fs.readFileSync(activityPath, 'utf-8');
|
||||||
|
content.split(/\r?\n/).forEach((l) => processLine(l, 'activity'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Access Logs
|
||||||
|
if (filterSource === 'auto' || filterSource === 'active') {
|
||||||
|
const accessDir = path.join(logsRootDir, 'access', year, month, day);
|
||||||
|
if (fs.existsSync(accessDir)) {
|
||||||
|
const files = fs.readdirSync(accessDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.log')) {
|
||||||
|
const content = fs.readFileSync(path.join(accessDir, file), 'utf-8');
|
||||||
|
content.split(/\r?\n/).forEach((l) => processLine(l, 'access'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Retention Log
|
||||||
|
if (filterSource === 'auto' || filterSource === 'active') {
|
||||||
|
const retentionPath = path.join(logsRootDir, 'retention.log');
|
||||||
|
if (fs.existsSync(retentionPath)) {
|
||||||
|
const content = fs.readFileSync(retentionPath, 'utf-8');
|
||||||
|
content.split(/\r?\n/).forEach((l) => {
|
||||||
|
if (!filterDate || l.includes(filterDate)) {
|
||||||
|
processLine(l, 'system');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. ZIP Archives (Only if explicitly selected or auto returns empty)
|
||||||
|
const backupDir = path.join(logsRootDir, 'backup');
|
||||||
|
let zipFilesToProcess = [];
|
||||||
|
if (fs.existsSync(backupDir)) {
|
||||||
|
if (filterSource.endsWith('.zip') && fs.existsSync(path.join(backupDir, filterSource))) {
|
||||||
|
zipFilesToProcess.push(path.join(backupDir, filterSource));
|
||||||
|
} else if (filterSource === 'all_zip' || (filterSource === 'auto' && logs.length === 0)) {
|
||||||
|
const files = fs.readdirSync(backupDir);
|
||||||
|
const ymMonth = `${year}-${month}`;
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.zip')) {
|
||||||
|
if (filterSource === 'auto' && filterDate) {
|
||||||
|
if (file.includes(ymMonth)) {
|
||||||
|
zipFilesToProcess.push(path.join(backupDir, file));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
zipFilesToProcess.push(path.join(backupDir, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const zipPath of zipFilesToProcess) {
|
||||||
|
try {
|
||||||
|
const zip = new AdmZip(zipPath);
|
||||||
|
const entries = zip.getEntries();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory) continue;
|
||||||
|
const entryName = entry.entryName;
|
||||||
|
if (filterSource === 'auto' && filterDate) {
|
||||||
|
if (!entryName.includes(filterDate) && !entryName.includes(dmY) && !entryName.includes(`/${day}/`)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ch = 'archive';
|
||||||
|
if (entryName.includes('session')) ch = 'session';
|
||||||
|
else if (entryName.includes('auth')) ch = 'auth';
|
||||||
|
else if (entryName.includes('error')) ch = 'error';
|
||||||
|
else if (entryName.includes('performance')) ch = 'performance';
|
||||||
|
else if (entryName.includes('security')) ch = 'security';
|
||||||
|
else if (entryName.includes('access')) ch = 'access';
|
||||||
|
else if (entryName.includes('activity')) ch = 'activity';
|
||||||
|
|
||||||
|
const content = entry.getData().toString('utf-8');
|
||||||
|
content.split(/\r?\n/).forEach((l) => processLine(l, ch));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error reading zip file ${zipPath}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (respCount > 0) {
|
||||||
|
stats.avg_response_ms = Number((totalRespMs / respCount).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast direct string timestamp comparison (100x faster than localeCompare/Date)
|
||||||
|
logs.sort((a, b) => {
|
||||||
|
const tsA = a.timestamp || '';
|
||||||
|
const tsB = b.timestamp || '';
|
||||||
|
if (tsA !== tsB) return tsB > tsA ? 1 : -1;
|
||||||
|
return (b._seq || 0) - (a._seq || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cap browser payload but keep full stats
|
||||||
|
const trimmed = logs.length > MAX_BROWSER_ITEMS ? logs.slice(0, MAX_BROWSER_ITEMS) : logs;
|
||||||
|
stats.total_scanned = logs.length;
|
||||||
|
|
||||||
|
return { logs: trimmed, stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBruteForceLocks(logsRootDir) {
|
||||||
|
const bfDir = path.join(logsRootDir, 'forensic', 'cache');
|
||||||
|
const lockedIPs = [];
|
||||||
|
if (fs.existsSync(bfDir)) {
|
||||||
|
const files = fs.readdirSync(bfDir);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith('bf_') && file.endsWith('.json')) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(path.join(bfDir, file), 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (data && data.lockout_until && data.lockout_until > now) {
|
||||||
|
lockedIPs.push(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore invalid json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lockedIPs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unblockIp(logsRootDir, targetIp) {
|
||||||
|
const bfDir = path.join(logsRootDir, 'forensic', 'cache');
|
||||||
|
if (fs.existsSync(bfDir)) {
|
||||||
|
const files = fs.readdirSync(bfDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith('bf_') && file.endsWith('.json')) {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(bfDir, file);
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (data && (data.ip === targetIp || file.includes(targetIp))) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupArchives(logsRootDir) {
|
||||||
|
const backupDir = path.join(logsRootDir, 'backup');
|
||||||
|
const backups = [];
|
||||||
|
if (fs.existsSync(backupDir)) {
|
||||||
|
const files = fs.readdirSync(backupDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.zip')) {
|
||||||
|
const filePath = path.join(backupDir, file);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
backups.push({
|
||||||
|
filename: file,
|
||||||
|
size_bytes: stat.size,
|
||||||
|
size_formatted: (stat.size / (1024 * 1024)).toFixed(2) + ' MB',
|
||||||
|
modified_time: new Date(stat.mtime).toISOString().replace('T', ' ').substring(0, 19),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backups.sort((a, b) => b.filename.localeCompare(a.filename));
|
||||||
|
return backups;
|
||||||
|
}
|
||||||
+383
@@ -0,0 +1,383 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
import { Navbar } from './components/Navbar';
|
||||||
|
import { LoginScreen } from './components/LoginScreen';
|
||||||
|
import { StatsOverview } from './components/StatsOverview';
|
||||||
|
import { BruteForcePanel } from './components/BruteForcePanel';
|
||||||
|
import { FilterToolbar } from './components/FilterToolbar';
|
||||||
|
import { LogTable } from './components/LogTable';
|
||||||
|
import { BackupArchives } from './components/BackupArchives';
|
||||||
|
import { LogEntry, LogStats, LockedIP, BackupFile, UserSession, FilterState } from './types';
|
||||||
|
import { ListFilter, Archive, Info, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [user, setUser] = useState<UserSession | null>(null);
|
||||||
|
const [authConfig, setAuthConfig] = useState<any>({});
|
||||||
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'logs' | 'backup'>('logs');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [actionMessage, setActionMessage] = useState('');
|
||||||
|
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [stats, setStats] = useState<LogStats>({
|
||||||
|
total: 0,
|
||||||
|
login_success: 0,
|
||||||
|
login_failed: 0,
|
||||||
|
session_expired: 0,
|
||||||
|
errors: 0,
|
||||||
|
warnings: 0,
|
||||||
|
performance_count: 0,
|
||||||
|
avg_response_ms: 0,
|
||||||
|
brute_force_locked: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lockedIPs, setLockedIPs] = useState<LockedIP[]>([]);
|
||||||
|
const [backups, setBackups] = useState<BackupFile[]>([]);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
totalLogs: 0,
|
||||||
|
perPage: 100,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
source: 'auto',
|
||||||
|
date: new Date().toISOString().substring(0, 10),
|
||||||
|
time_start: '',
|
||||||
|
time_end: '',
|
||||||
|
channel: 'all',
|
||||||
|
level: 'all',
|
||||||
|
search: '',
|
||||||
|
limit: 100,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check Auth State
|
||||||
|
const checkAuth = useCallback(async () => {
|
||||||
|
setAuthLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/auth/me');
|
||||||
|
if (res.data.authenticated) {
|
||||||
|
setUser(res.data.user);
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
setAuthConfig(res.data.config || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch auth state', e);
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch Logs Data with SweetAlert2 Loading Indicator
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Memuat Data Log Forensic...',
|
||||||
|
html: 'Sedang memindai dan mengurai data log dari server SIMRS...',
|
||||||
|
allowOutsideClick: false,
|
||||||
|
allowEscapeKey: false,
|
||||||
|
showConfirmButton: false,
|
||||||
|
didOpen: () => {
|
||||||
|
Swal.showLoading();
|
||||||
|
},
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
customClass: {
|
||||||
|
popup: 'border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl',
|
||||||
|
title: 'text-cyan-400 font-bold text-sm',
|
||||||
|
htmlContainer: 'text-slate-400 text-xs mt-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
source: filters.source,
|
||||||
|
date: filters.date,
|
||||||
|
channel: filters.channel,
|
||||||
|
level: filters.level,
|
||||||
|
time_start: filters.time_start,
|
||||||
|
time_end: filters.time_end,
|
||||||
|
search: filters.search,
|
||||||
|
page: String(filters.page),
|
||||||
|
limit: String(filters.limit),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await axios.get(`/api/logs?${params.toString()}`);
|
||||||
|
const data = res.data;
|
||||||
|
|
||||||
|
setLogs(data.logs || []);
|
||||||
|
setStats(data.stats || {});
|
||||||
|
setLockedIPs(data.lockedIPs || []);
|
||||||
|
setBackups(data.backups || []);
|
||||||
|
if (data.pagination) {
|
||||||
|
setPagination({
|
||||||
|
totalLogs: data.pagination.totalLogs,
|
||||||
|
perPage: data.pagination.perPage,
|
||||||
|
currentPage: data.pagination.currentPage,
|
||||||
|
totalPages: data.pagination.totalPages,
|
||||||
|
offset: data.pagination.offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Swal.close();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch logs', err);
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Gagal Memuat Log',
|
||||||
|
text: err.response?.data?.error || err.message || 'Terjadi kesalahan saat memproses data log.',
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
confirmButtonColor: '#0e7490',
|
||||||
|
customClass: {
|
||||||
|
popup: 'border border-slate-800 rounded-2xl shadow-2xl',
|
||||||
|
title: 'text-rose-400 font-bold text-sm',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchLogs();
|
||||||
|
}
|
||||||
|
}, [fetchLogs, user]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleLogin = async (credentials: any) => {
|
||||||
|
const res = await axios.post('/api/auth/login', credentials);
|
||||||
|
if (res.data.success) {
|
||||||
|
setUser(res.data.user);
|
||||||
|
} else {
|
||||||
|
throw new Error(res.data.error || 'Login gagal.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await axios.post('/api/auth/logout');
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnblock = async (ip: string) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/api/brute-force/unblock', { target_ip: ip });
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'success',
|
||||||
|
title: 'Unblock Berhasil',
|
||||||
|
text: res.data.message || `IP ${ip} berhasil dibuka blokirnya.`,
|
||||||
|
timer: 2000,
|
||||||
|
showConfirmButton: false,
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
});
|
||||||
|
fetchLogs();
|
||||||
|
} catch (err: any) {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Gagal Unblock',
|
||||||
|
text: err.response?.data?.error || 'Gagal unblock IP.',
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunRetention = async () => {
|
||||||
|
try {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Jalankan Job Retention & Backup',
|
||||||
|
text: 'Apakah Anda yakin ingin memicu job pembersihan & pencadangan log manual?',
|
||||||
|
icon: 'question',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Ya, Jalankan',
|
||||||
|
cancelButtonText: 'Batal',
|
||||||
|
confirmButtonColor: '#0e7490',
|
||||||
|
cancelButtonColor: '#334155',
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
}).then(async (result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
const res = await axios.post('/api/retention/run');
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'success',
|
||||||
|
title: 'Job Retention Berhasil',
|
||||||
|
text: res.data.message || 'Retention job berhasil dijalankan.',
|
||||||
|
timer: 2500,
|
||||||
|
showConfirmButton: false,
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
});
|
||||||
|
fetchLogs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Gagal Retention',
|
||||||
|
text: 'Gagal menjalankan retention job.',
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = (type: 'csv' | 'json') => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type,
|
||||||
|
source: filters.source,
|
||||||
|
date: filters.date,
|
||||||
|
channel: filters.channel,
|
||||||
|
level: filters.level,
|
||||||
|
search: filters.search,
|
||||||
|
});
|
||||||
|
window.open(`/api/export?${params.toString()}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard Shortcuts Listener
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
(e.ctrlKey && e.altKey && (e.key === 'f' || e.key === 'F')) ||
|
||||||
|
(e.ctrlKey && e.shiftKey && (e.key === 'F' || e.key === 'f'))
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 1. Initial Loading State
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
|
||||||
|
<p className="text-xs text-slate-400 font-medium">Memeriksa Sesi Autentikasi...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. MANDATORY LOGIN CHECK: If not logged in, show full-screen LoginScreen exclusively!
|
||||||
|
if (!user) {
|
||||||
|
return <LoginScreen onLogin={handleLogin} defaultConfig={authConfig} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. FULLY RESPONSIVE DASHBOARD (Only accessible when logged in)
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col w-full">
|
||||||
|
<Navbar user={user} simrsUrl={authConfig.simrsUrl} onOpenLogin={() => {}} onLogout={handleLogout} />
|
||||||
|
|
||||||
|
<main className="flex-1 w-full px-4 md:px-8 py-6">
|
||||||
|
{actionMessage && (
|
||||||
|
<div className="mb-5 p-3.5 rounded-xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 text-xs flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info className="w-4 h-4 text-cyan-400 shrink-0" />
|
||||||
|
<span>{actionMessage}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setActionMessage('')} className="text-slate-400 hover:text-white font-bold cursor-pointer">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Section */}
|
||||||
|
<StatsOverview stats={stats} selectedDate={filters.date} />
|
||||||
|
|
||||||
|
{/* Brute Force Active Alerts */}
|
||||||
|
<BruteForcePanel lockedIPs={lockedIPs} onUnblock={handleUnblock} />
|
||||||
|
|
||||||
|
{/* Filter Toolbar */}
|
||||||
|
<FilterToolbar
|
||||||
|
filters={filters}
|
||||||
|
backups={backups}
|
||||||
|
onChange={(updated) => setFilters((prev) => ({ ...prev, ...updated }))}
|
||||||
|
onReset={() =>
|
||||||
|
setFilters({
|
||||||
|
source: 'auto',
|
||||||
|
date: new Date().toISOString().substring(0, 10),
|
||||||
|
time_start: '',
|
||||||
|
time_end: '',
|
||||||
|
channel: 'all',
|
||||||
|
level: 'all',
|
||||||
|
search: '',
|
||||||
|
limit: 100,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Tabbed Layout */}
|
||||||
|
<div className="mb-6 w-full">
|
||||||
|
<div className="flex items-center border-b border-slate-800 mb-4 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
className={`flex items-center gap-2 px-5 py-3 text-xs font-semibold border-b-2 transition-all cursor-pointer ${
|
||||||
|
activeTab === 'logs'
|
||||||
|
? 'border-cyan-400 text-cyan-400 bg-slate-900/60 rounded-t-xl'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListFilter className="w-4 h-4" />
|
||||||
|
Log Entries ({pagination.totalLogs.toLocaleString()} baris)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('backup')}
|
||||||
|
className={`flex items-center gap-2 px-5 py-3 text-xs font-semibold border-b-2 transition-all cursor-pointer ${
|
||||||
|
activeTab === 'backup'
|
||||||
|
? 'border-amber-400 text-amber-400 bg-slate-900/60 rounded-t-xl'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Archive className="w-4 h-4" />
|
||||||
|
Arsip Backup ZIP ({backups.length} file)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'logs' ? (
|
||||||
|
<LogTable
|
||||||
|
logs={logs}
|
||||||
|
totalLogs={pagination.totalLogs}
|
||||||
|
currentPage={pagination.currentPage}
|
||||||
|
totalPages={pagination.totalPages}
|
||||||
|
limit={pagination.perPage}
|
||||||
|
offset={pagination.offset}
|
||||||
|
loading={loading}
|
||||||
|
onPageChange={(page) => setFilters((prev) => ({ ...prev, page }))}
|
||||||
|
onLimitChange={(limit) => setFilters((prev) => ({ ...prev, limit, page: 1 }))}
|
||||||
|
onLoadMore={() => setFilters((prev) => ({ ...prev, limit: prev.limit + 100 }))}
|
||||||
|
onExport={handleExport}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BackupArchives
|
||||||
|
backups={backups}
|
||||||
|
onSelectBackup={(filename) => {
|
||||||
|
setFilters((prev) => ({ ...prev, source: filename, page: 1 }));
|
||||||
|
setActiveTab('logs');
|
||||||
|
}}
|
||||||
|
onRunRetention={handleRunRetention}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Archive, ShieldCheck, Eye, RefreshCw } from 'lucide-react';
|
||||||
|
import { BackupFile } from '../types';
|
||||||
|
|
||||||
|
interface BackupArchivesProps {
|
||||||
|
backups: BackupFile[];
|
||||||
|
onSelectBackup: (filename: string) => void;
|
||||||
|
onRunRetention: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BackupArchives: React.FC<BackupArchivesProps> = ({ backups, onSelectBackup, onRunRetention }) => {
|
||||||
|
return (
|
||||||
|
<div className="glass-card rounded-xl overflow-hidden mb-6">
|
||||||
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900/60">
|
||||||
|
<div className="text-xs text-slate-400 flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-amber-400" />
|
||||||
|
<span>Berkas log berusia >30 hari diarsipkan secara kompresi per bulan.</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRunRetention}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs font-semibold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" /> Jalankan Retention & Backup Manual
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{backups.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-slate-500 text-xs">
|
||||||
|
<Archive className="w-10 h-10 mx-auto mb-3 opacity-30 text-slate-400" />
|
||||||
|
Belum ada file arsip log zip di folder <code className="text-amber-400">logs/backup/</code>. File log berusia >30 hari akan diarsipkan otomatis.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead className="bg-slate-950/80 text-slate-400 font-semibold border-b border-slate-800">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3">Nama Berkas Arsip</th>
|
||||||
|
<th className="p-3">Ukuran File</th>
|
||||||
|
<th className="p-3">Waktu Pembuatan</th>
|
||||||
|
<th className="p-3 text-right">Aksi & Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
|
{backups.map((bk, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="p-3 font-semibold text-slate-200 flex items-center gap-2">
|
||||||
|
<Archive className="w-4 h-4 text-amber-400 shrink-0" />
|
||||||
|
<span>{bk.filename}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-slate-400">{bk.size_mb} MB</td>
|
||||||
|
<td className="p-3 text-slate-400">{bk.mtime}</td>
|
||||||
|
<td className="p-3 text-right flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectBackup(bk.filename)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs font-semibold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" /> Baca Log ZIP
|
||||||
|
</button>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[11px]">
|
||||||
|
<ShieldCheck className="w-3 h-3" /> Safe Archive
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ShieldAlert, Unlock } from 'lucide-react';
|
||||||
|
import { LockedIP } from '../types';
|
||||||
|
|
||||||
|
interface BruteForcePanelProps {
|
||||||
|
lockedIPs: LockedIP[];
|
||||||
|
onUnblock: (ip: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BruteForcePanel: React.FC<BruteForcePanelProps> = ({ lockedIPs, onUnblock }) => {
|
||||||
|
if (lockedIPs.length === 0) return null;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card border-rose-500/40 rounded-xl mb-6 overflow-hidden">
|
||||||
|
<div className="bg-rose-500/10 border-b border-rose-500/20 px-4 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-rose-400 font-semibold text-sm">
|
||||||
|
<ShieldAlert className="w-5 h-5 text-rose-500" />
|
||||||
|
<span>Peringatan Keamanan: IP Terblokir (Brute Force Lockout Detected)</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs px-2.5 py-1 rounded-full bg-rose-500/20 text-rose-300 font-bold border border-rose-500/30">
|
||||||
|
{lockedIPs.length} IP
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead className="bg-slate-900/60 text-slate-400 font-semibold border-b border-slate-800">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3">IP Address</th>
|
||||||
|
<th className="p-3">Percobaan Gagal</th>
|
||||||
|
<th className="p-3">Sisa Waktu Lockout</th>
|
||||||
|
<th className="p-3 text-right">Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
|
{lockedIPs.map((item, idx) => {
|
||||||
|
const remainSeconds = Math.max(0, item.lockout_until - now);
|
||||||
|
const remainMinutes = Math.ceil(remainSeconds / 60);
|
||||||
|
return (
|
||||||
|
<tr key={idx} className="hover:bg-slate-800/40">
|
||||||
|
<td className="p-3 font-mono font-bold text-amber-400">{item.ip}</td>
|
||||||
|
<td className="p-3">{item.attempts?.length || 0}x Percobaan</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{remainMinutes} Menit ({new Date(item.lockout_until * 1000).toLocaleTimeString()})
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => onUnblock(item.ip)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-md bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 font-semibold text-xs transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Unlock className="w-3.5 h-3.5" />
|
||||||
|
Buka Blokir
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Database, Calendar, Clock, Layers, AlertCircle, Search, Filter, RotateCcw } from 'lucide-react';
|
||||||
|
import { FilterState, BackupFile } from '../types';
|
||||||
|
|
||||||
|
interface FilterToolbarProps {
|
||||||
|
filters: FilterState;
|
||||||
|
backups: BackupFile[];
|
||||||
|
onChange: (updated: Partial<FilterState>) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterToolbar: React.FC<FilterToolbarProps> = ({ filters, backups, onChange, onReset }) => {
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-4 rounded-xl mb-6">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange({ page: 1 });
|
||||||
|
}}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-7 gap-3 items-end"
|
||||||
|
>
|
||||||
|
{/* Sumber Log */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
|
||||||
|
<Database className="w-3.5 h-3.5 text-cyan-400" /> Sumber Log
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.source}
|
||||||
|
onChange={(e) => onChange({ source: e.target.value, page: 1 })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value="auto">⚡ Auto (Aktif + ZIP)</option>
|
||||||
|
<option value="active">📁 Log Aktif</option>
|
||||||
|
<option value="all_zip">📦 Semua Arsip ZIP</option>
|
||||||
|
{backups.length > 0 && (
|
||||||
|
<optgroup label="File ZIP:">
|
||||||
|
{backups.map((b) => (
|
||||||
|
<option key={b.filename} value={b.filename}>
|
||||||
|
📦 {b.filename}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tanggal */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-cyan-400" /> Tanggal Log
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.date}
|
||||||
|
onChange={(e) => onChange({ date: e.target.value, page: 1 })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rentang Jam */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-cyan-400" /> Rentang Jam
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={filters.time_start}
|
||||||
|
onChange={(e) => onChange({ time_start: e.target.value, page: 1 })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-1.5 py-1.5 text-[11px] text-white focus:border-cyan-500 outline-none"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-500 text-xs">-</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={filters.time_end}
|
||||||
|
onChange={(e) => onChange({ time_end: e.target.value, page: 1 })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-1.5 py-1.5 text-[11px] text-white focus:border-cyan-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
|
||||||
|
<Layers className="w-3.5 h-3.5 text-cyan-400" /> Channel
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.channel}
|
||||||
|
onChange={(e) => onChange({ channel: e.target.value, page: 1 })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">-- Semua Channel --</option>
|
||||||
|
<option value="session">Session Lifecycle</option>
|
||||||
|
<option value="auth">Autentikasi & Login</option>
|
||||||
|
<option value="access">Akses Halaman</option>
|
||||||
|
<option value="activity">Aktivitas User</option>
|
||||||
|
<option value="error">Error & Exception</option>
|
||||||
|
<option value="performance">Performance & Query</option>
|
||||||
|
<option value="security">Security & BruteForce</option>
|
||||||
|
<option value="system">System & Retention</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Level */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 text-cyan-400" /> Level
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.level}
|
||||||
|
onChange={(e) => onChange({ level: e.target.value, page: 1 })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">Semua</option>
|
||||||
|
<option value="DEBUG">DEBUG</option>
|
||||||
|
<option value="INFO">INFO</option>
|
||||||
|
<option value="WARNING">WARN</option>
|
||||||
|
<option value="ERROR">ERR</option>
|
||||||
|
<option value="CRITICAL">CRIT</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kata Kunci */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
|
||||||
|
<Search className="w-3.5 h-3.5 text-cyan-400" /> Kata Kunci
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="NIP, URI, IP, Pesan..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => onChange({ search: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 py-1.5 px-3 rounded-lg bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs flex items-center justify-center gap-1 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Filter className="w-3.5 h-3.5" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReset}
|
||||||
|
className="py-1.5 px-2.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-xs transition-colors cursor-pointer"
|
||||||
|
title="Reset Filter"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Code, Copy, Check, Eye, EyeOff, FileText, Download, ChevronLeft, ChevronRight, PlusCircle, Link as LinkIcon } from 'lucide-react';
|
||||||
|
import { LogEntry } from '../types';
|
||||||
|
|
||||||
|
interface LogTableProps {
|
||||||
|
logs: LogEntry[];
|
||||||
|
totalLogs: number;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
loading: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
onExport: (type: 'csv' | 'json') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogTable: React.FC<LogTableProps> = ({
|
||||||
|
logs,
|
||||||
|
totalLogs,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
loading,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onLoadMore,
|
||||||
|
onExport,
|
||||||
|
}) => {
|
||||||
|
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
|
||||||
|
const [expandedMsgs, setExpandedMsgs] = useState<Record<number, boolean>>({});
|
||||||
|
const [copiedRow, setCopiedRow] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const toggleRow = (idx: number) => {
|
||||||
|
setExpandedRows((prev) => ({ ...prev, [idx]: !prev[idx] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMsg = (idx: number) => {
|
||||||
|
setExpandedMsgs((prev) => ({ ...prev, [idx]: !prev[idx] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackCopyText = (text: string, idx: number) => {
|
||||||
|
try {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.top = '0';
|
||||||
|
textArea.style.left = '0';
|
||||||
|
textArea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
if (successful) {
|
||||||
|
setCopiedRow(idx);
|
||||||
|
setTimeout(() => setCopiedRow(null), 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Gagal menyalin JSON:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyJson = (log: LogEntry, idx: number) => {
|
||||||
|
const jsonStr = JSON.stringify(log, null, 2);
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(jsonStr)
|
||||||
|
.then(() => {
|
||||||
|
setCopiedRow(idx);
|
||||||
|
setTimeout(() => setCopiedRow(null), 2000);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
fallbackCopyText(jsonStr, idx);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopyText(jsonStr, idx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelBadge = (level: string) => {
|
||||||
|
const lvl = (level || 'INFO').toUpperCase();
|
||||||
|
switch (lvl) {
|
||||||
|
case 'CRITICAL':
|
||||||
|
return 'bg-rose-600 text-white font-bold';
|
||||||
|
case 'ERROR':
|
||||||
|
return 'bg-orange-500 text-white font-bold';
|
||||||
|
case 'WARNING':
|
||||||
|
return 'bg-amber-400 text-slate-950 font-bold';
|
||||||
|
case 'DEBUG':
|
||||||
|
return 'bg-slate-600 text-white';
|
||||||
|
default:
|
||||||
|
return 'bg-cyan-600 text-white';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card rounded-xl overflow-hidden mb-6 w-full max-w-full">
|
||||||
|
{/* Header Bar */}
|
||||||
|
<div className="p-4 border-b border-slate-800 flex flex-col md:flex-row md:items-center justify-between gap-3 bg-slate-900/60">
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
Menampilkan <strong className="text-white">{totalLogs > 0 ? offset + 1 : 0} - {Math.min(offset + logs.length, totalLogs)}</strong> dari{' '}
|
||||||
|
<strong className="text-white">{totalLogs.toLocaleString()}</strong> baris (Halaman {currentPage} dari {totalPages})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<span>Limit:</span>
|
||||||
|
<select
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => onLimitChange(Number(e.target.value))}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded px-2 py-1 text-xs text-white focus:border-cyan-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value={50}>50 / hlm</option>
|
||||||
|
<option value={100}>100 / hlm (Default)</option>
|
||||||
|
<option value={250}>250 / hlm</option>
|
||||||
|
<option value={500}>500 / hlm</option>
|
||||||
|
<option value={1000}>1000 / hlm</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onExport('csv')}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 rounded bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 text-xs font-semibold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Export CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onExport('json')}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 rounded bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs font-semibold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Export JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Container */}
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
|
<table className="w-full text-left text-xs text-slate-300 table-fixed">
|
||||||
|
<thead className="bg-slate-950/80 text-slate-400 font-semibold border-b border-slate-800">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 w-[110px]">Waktu</th>
|
||||||
|
<th className="p-3 w-[70px]">Level</th>
|
||||||
|
<th className="p-3 w-[90px]">Channel</th>
|
||||||
|
<th className="p-3 w-[110px]">IP Address</th>
|
||||||
|
<th className="p-3 w-[110px]">User NIP</th>
|
||||||
|
<th className="p-3">Pesan / Event</th>
|
||||||
|
<th className="p-3 w-[60px] text-center">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 font-sans">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="text-center py-12 text-slate-500">
|
||||||
|
<FileText className="w-8 h-8 mx-auto mb-2 opacity-40" />
|
||||||
|
Tidak ada log ditemukan untuk kriteria filter ini.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log, idx) => {
|
||||||
|
const isMsgLong = (log.message || '').length > 130;
|
||||||
|
const isMsgExpanded = expandedMsgs[idx];
|
||||||
|
const isRowExpanded = expandedRows[idx];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<tr className="hover:bg-slate-800/30 transition-colors align-top">
|
||||||
|
<td className="p-3 truncate">
|
||||||
|
<div className="text-[11px] text-slate-500">{log.timestamp ? log.timestamp.substring(0, 10) : '-'}</div>
|
||||||
|
<div className="font-semibold text-slate-200">{log.timestamp ? log.timestamp.substring(11, 19) : '-'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] uppercase ${getLevelBadge(log.level)}`}>
|
||||||
|
{log.level || 'INFO'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 truncate">
|
||||||
|
<span className="px-2 py-0.5 rounded bg-slate-800 border border-slate-700 text-cyan-400 font-mono text-[11px] truncate inline-block max-w-full" title={log.channel || '-'}>
|
||||||
|
{log.channel || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-slate-300 text-[11px] truncate" title={log.context?.ip_address || '-'}>
|
||||||
|
{log.context?.ip_address || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-medium text-amber-400 text-[11px] truncate" title={log.context?.user_nip || '-'}>
|
||||||
|
{log.context?.user_nip || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-[12px] leading-relaxed break-all break-words whitespace-pre-wrap overflow-hidden max-w-full">
|
||||||
|
<div>
|
||||||
|
{isMsgLong && !isMsgExpanded ? (
|
||||||
|
<span>
|
||||||
|
{log.message.substring(0, 130)}...{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMsg(idx)}
|
||||||
|
className="text-cyan-400 hover:underline text-[11px] inline-flex items-center gap-0.5 ml-1 font-sans cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3" /> Detail
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{log.message}
|
||||||
|
{isMsgLong && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMsg(idx)}
|
||||||
|
className="text-slate-400 hover:underline text-[11px] inline-flex items-center gap-0.5 ml-1 font-sans cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
<EyeOff className="w-3 h-3" /> Ringkas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{log.context?.request_uri && (
|
||||||
|
<div className="text-[11px] text-slate-500 font-sans mt-1 flex items-center gap-1 truncate max-w-full" title={log.context.request_uri}>
|
||||||
|
<LinkIcon className="w-3 h-3 text-slate-600 shrink-0" />
|
||||||
|
<span className="truncate">{log.context.request_uri}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleRow(idx)}
|
||||||
|
className="p-1 rounded border border-slate-700 hover:bg-slate-800 text-cyan-400 transition-colors cursor-pointer"
|
||||||
|
title="Lihat JSON mentah"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expandable JSON detail row */}
|
||||||
|
{isRowExpanded && (
|
||||||
|
<tr className="bg-slate-950/90">
|
||||||
|
<td colSpan={7} className="p-4 border-t border-b border-slate-800">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-semibold text-cyan-400 flex items-center gap-1.5">
|
||||||
|
<Code className="w-4 h-4" /> Data Log JSON Structure (Row #{offset + idx + 1})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyJson(log, idx)}
|
||||||
|
className="px-2.5 py-1 rounded border border-slate-700 bg-slate-900 hover:bg-slate-800 text-slate-300 text-xs flex items-center gap-1 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{copiedRow === idx ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span className="text-emerald-400 font-semibold">Tersalin!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
<span>Copy JSON</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="json-box p-3 rounded-lg overflow-x-auto max-h-80 w-full">
|
||||||
|
<pre className="text-xs text-cyan-300 font-mono whitespace-pre-wrap break-all break-words">
|
||||||
|
{JSON.stringify(log, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Footer */}
|
||||||
|
{totalLogs > 0 && (
|
||||||
|
<div className="p-4 border-t border-slate-800 flex flex-col md:flex-row items-center justify-between gap-3 bg-slate-900/60">
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
Halaman <strong className="text-white">{currentPage}</strong> dari <strong className="text-white">{totalPages}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
disabled={currentPage <= 1 || loading}
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
className="p-1.5 rounded bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 transition-all cursor-pointer"
|
||||||
|
title="Halaman Sebelumnya"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-xs text-slate-400 px-2">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={currentPage >= totalPages || loading}
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
className="p-1.5 rounded bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 transition-all cursor-pointer"
|
||||||
|
title="Halaman Berikutnya"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentPage < totalPages && (
|
||||||
|
<button
|
||||||
|
onClick={onLoadMore}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-500 text-white text-xs font-semibold flex items-center gap-1.5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-4 h-4" /> Muat Log Berikutnya (Lazy Load)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Key, User, Lock, Settings, AlertTriangle, Eye, EyeOff, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onLogin: (credentials: any) => Promise<void>;
|
||||||
|
defaultConfig?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginModal: React.FC<LoginModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onLogin,
|
||||||
|
defaultConfig = {},
|
||||||
|
}) => {
|
||||||
|
const [username, setUsername] = useState('[email protected]');
|
||||||
|
const [password, setPassword] = useState('RSSAjaya2026');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [kcServer, setKcServer] = useState(defaultConfig.defaultKcServer || 'https://auth.rssa.top/');
|
||||||
|
const [kcRealm, setKcRealm] = useState(defaultConfig.defaultKcRealm || 'rssa');
|
||||||
|
const [kcClientId, setKcClientId] = useState(defaultConfig.defaultKcClient || 'satu');
|
||||||
|
const [kcClientSecret, setKcClientSecret] = useState(defaultConfig.defaultKcSecret || 'ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE');
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onLogin({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
kc_server: kcServer,
|
||||||
|
kc_realm: kcRealm,
|
||||||
|
kc_client_id: kcClientId,
|
||||||
|
kc_client_secret: kcClientSecret,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Login gagal. Periksa username dan password Anda.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl relative">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-tr from-teal-500 to-cyan-500 rounded-2xl flex items-center justify-center mx-auto mb-3 shadow-lg shadow-teal-500/30">
|
||||||
|
<Key className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white">SIMRS Forensic Inspector</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Masuk menggunakan Keycloak SSO / Local Login</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 text-rose-400 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Username / Email Keycloak</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
required
|
||||||
|
className="w-full pl-9 pr-3 py-2 rounded-lg bg-slate-950 border border-slate-800 text-white text-sm focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Masukkan Password"
|
||||||
|
required
|
||||||
|
className="w-full pl-9 pr-10 py-2 rounded-lg bg-slate-950 border border-slate-800 text-white text-sm focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-500 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4 text-cyan-400" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Keycloak Server Accordion */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="text-xs text-cyan-400 hover:underline flex items-center gap-1 font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<Settings className="w-3.5 h-3.5" />
|
||||||
|
Pengaturan Server Keycloak (Advanced)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-slate-950 border border-slate-800 space-y-3 animate-in fade-in duration-150">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-slate-400 mb-1">Keycloak Server Base URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={kcServer}
|
||||||
|
onChange={(e) => setKcServer(e.target.value)}
|
||||||
|
className="w-full px-2.5 py-1.5 rounded bg-slate-900 border border-slate-800 text-white text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-slate-400 mb-1">Realm</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={kcRealm}
|
||||||
|
onChange={(e) => setKcRealm(e.target.value)}
|
||||||
|
className="w-full px-2.5 py-1.5 rounded bg-slate-900 border border-slate-800 text-white text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-slate-400 mb-1">Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={kcClientId}
|
||||||
|
onChange={(e) => setKcClientId(e.target.value)}
|
||||||
|
className="w-full px-2.5 py-1.5 rounded bg-slate-900 border border-slate-800 text-white text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-slate-400 mb-1">Client Secret</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={kcClientSecret}
|
||||||
|
onChange={(e) => setKcClientSecret(e.target.value)}
|
||||||
|
className="w-full px-2.5 py-1.5 rounded bg-slate-900 border border-slate-800 text-white text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2.5 rounded-lg bg-teal-600 hover:bg-teal-500 text-white font-semibold text-sm shadow-lg shadow-teal-600/30 transition-all flex items-center justify-center gap-2 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? 'Memproses...' : 'Masuk (Keycloak SSO / Local)'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Key, User, Lock, Settings, AlertTriangle, Eye, EyeOff, Keyboard, ArrowLeft, ExternalLink, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LoginScreenProps {
|
||||||
|
onLogin: (credentials: any) => Promise<void>;
|
||||||
|
defaultConfig?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin, defaultConfig = {} }) => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isKcEnvConfigured = defaultConfig?.isKcEnvConfigured ?? false;
|
||||||
|
|
||||||
|
const [kcServer, setKcServer] = useState(defaultConfig.defaultKcServer || 'https://auth.rssa.top/');
|
||||||
|
const [kcRealm, setKcRealm] = useState(defaultConfig.defaultKcRealm || 'rssa');
|
||||||
|
const [kcClientId, setKcClientId] = useState(defaultConfig.defaultKcClient || 'satu');
|
||||||
|
const [kcClientSecret, setKcClientSecret] = useState(defaultConfig.defaultKcSecret || 'ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE');
|
||||||
|
const [kcRedirectUri, setKcRedirectUri] = useState('');
|
||||||
|
|
||||||
|
// Check URL query error
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const errParam = params.get('error');
|
||||||
|
if (errParam) {
|
||||||
|
setError(decodeURIComponent(errParam));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePkceLogin = () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
kc_server: kcServer,
|
||||||
|
kc_realm: kcRealm,
|
||||||
|
kc_client_id: kcClientId,
|
||||||
|
kc_client_secret: kcClientSecret,
|
||||||
|
});
|
||||||
|
if (kcRedirectUri) {
|
||||||
|
params.append('kc_redirect_uri', kcRedirectUri);
|
||||||
|
}
|
||||||
|
window.location.href = `/api/auth/pkce/login?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onLogin({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
kc_server: kcServer,
|
||||||
|
kc_realm: kcRealm,
|
||||||
|
kc_client_id: kcClientId,
|
||||||
|
kc_client_secret: kcClientSecret,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Login gagal. Periksa username dan password.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const simrsUrl = defaultConfig?.simrsUrl || '/';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
{/* Ambient Glows */}
|
||||||
|
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-teal-500/10 rounded-full blur-[140px] pointer-events-none" />
|
||||||
|
<div className="absolute bottom-10 right-10 w-[400px] h-[400px] bg-cyan-500/10 rounded-full blur-[120px] pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="w-full max-w-md bg-slate-900/90 backdrop-blur-xl border border-slate-800 rounded-3xl p-8 shadow-2xl shadow-slate-950 relative z-10">
|
||||||
|
{/* Brand Icon */}
|
||||||
|
<div className="w-20 h-20 bg-gradient-to-tr from-teal-500 to-cyan-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-xl shadow-teal-500/30">
|
||||||
|
<Key className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-white">SIMRS Logs Inspector</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1.5 flex items-center justify-center gap-1">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-teal-400" />
|
||||||
|
Keamanan SSO Terproteksi Standar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-5 p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-start gap-2.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 text-rose-400 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Primary PKCE Login Action */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePkceLogin}
|
||||||
|
className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-teal-500 via-cyan-500 to-blue-600 hover:from-teal-400 hover:to-cyan-400 text-white font-bold text-sm shadow-xl shadow-teal-500/25 transition-all flex items-center justify-center gap-2.5 cursor-pointer group"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-5 h-5 text-cyan-200 group-hover:scale-110 transition-transform" />
|
||||||
|
<span>Masuk via Keycloak SSO</span>
|
||||||
|
<ExternalLink className="w-4 h-4 text-cyan-200" />
|
||||||
|
</button>
|
||||||
|
<p className="text-[11px] text-slate-500 text-center mt-2">
|
||||||
|
Direkomendasikan. Pengalihan aman ke server identitas Keycloak.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex py-2 items-center mb-6">
|
||||||
|
<div className="flex-grow border-t border-slate-800"></div>
|
||||||
|
<span className="flex-shrink mx-4 text-[11px] text-slate-500 font-semibold uppercase">Atau Akun Lokal / Pengujian</span>
|
||||||
|
<div className="flex-grow border-t border-slate-800"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local / Direct Fallback Login Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||||
|
Username / Email (Local / Dev)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Username / Email (cth: [email protected])"
|
||||||
|
className="w-full pl-10 pr-3.5 py-2 rounded-xl bg-slate-950/80 border border-slate-800 text-white text-sm focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Masukkan Password"
|
||||||
|
className="w-full pl-10 pr-10 py-2 rounded-xl bg-slate-950/80 border border-slate-800 text-white text-sm focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
|
title="Tampilkan / Sembunyikan Password"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4 text-cyan-400" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Keycloak Server Config (Ditampilkan HANYA jika .env Keycloak belum terkonfigurasi) */}
|
||||||
|
{!isKcEnvConfigured && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="text-xs text-cyan-400 hover:underline flex items-center gap-1 font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<Settings className="w-3.5 h-3.5" />
|
||||||
|
Pengaturan Server Keycloak (Advanced)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="mt-3 p-3.5 rounded-xl bg-slate-950/90 border border-slate-800 space-y-3 animate-in fade-in duration-150">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-slate-400 mb-1">Keycloak Server Base URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={kcServer}
|
||||||
|
onChange={(e) => setKcServer(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-slate-400 mb-1">Realm</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={kcRealm}
|
||||||
|
onChange={(e) => setKcRealm(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-slate-400 mb-1">Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={kcClientId}
|
||||||
|
onChange={(e) => setKcClientId(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-slate-400 mb-1">Client Secret</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={kcClientSecret}
|
||||||
|
onChange={(e) => setKcClientSecret(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-slate-400 mb-1">Custom Redirect URI (Opsional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={kcRedirectUri}
|
||||||
|
onChange={(e) => setKcRedirectUri(e.target.value)}
|
||||||
|
placeholder="http://meninjar.dev.rssa.id:5880/api/auth/pkce/callback"
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 font-semibold text-xs transition-all flex items-center justify-center gap-2 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? 'Memproses...' : 'Masuk Akun Lokal / Pengujian'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Secret Hotkey Hint */}
|
||||||
|
{/* <div className="mt-6 text-center">
|
||||||
|
<div
|
||||||
|
onClick={() =>
|
||||||
|
alert(
|
||||||
|
'🔑 Secret Key Shortcuts:\n\n1. [Ctrl + Alt + F] atau [Ctrl + Shift + F] : Langsung menuju Forensic Inspector\n2. [Ctrl + Shift + K] : Fokus cepat ke kolom Username SSO\n3. Ketik kata "forensic" atau "inspector" kapan saja.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-950 border border-slate-800 text-cyan-400 text-xs font-mono cursor-pointer hover:border-cyan-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Keyboard className="w-3.5 h-3.5" />
|
||||||
|
<span>Shortcut: <kbd className="bg-slate-800 text-white px-1.5 py-0.5 rounded">Ctrl+Alt+F</kbd> / <code>forensic</code></span>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<a
|
||||||
|
href={simrsUrl}
|
||||||
|
className="text-xs text-slate-500 hover:text-slate-300 inline-flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
Kembali ke SIMRS Utama
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Search, UserCheck, Power, Key, Home } from 'lucide-react';
|
||||||
|
import { UserSession } from '../types';
|
||||||
|
|
||||||
|
interface NavbarProps {
|
||||||
|
user: UserSession | null;
|
||||||
|
simrsUrl?: string;
|
||||||
|
onOpenLogin: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Navbar: React.FC<NavbarProps> = ({ user, simrsUrl = '/', onLogout }) => {
|
||||||
|
const handlePkceLogin = () => {
|
||||||
|
window.location.href = '/api/auth/pkce/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-slate-900/90 backdrop-blur-md border-b border-slate-800 sticky top-0 z-40 px-4 md:px-8 py-3 shadow-lg w-full">
|
||||||
|
<div className="w-full flex items-center justify-between">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-teal-500 to-cyan-500 flex items-center justify-center shadow-lg shadow-teal-500/20">
|
||||||
|
<Search className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold tracking-tight text-white flex items-center gap-2">
|
||||||
|
SIMRS Logs Inspector
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400">System Diagnostics, Audit & Security Monitor</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-800/80 border border-slate-700 text-xs font-medium text-cyan-300">
|
||||||
|
<UserCheck className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>SSO ({user.auth_type?.includes('pkce') ? 'PKCE' : 'Local'}): {user.name || user.username}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-300 hover:bg-rose-500/20 text-xs font-semibold transition-colors cursor-pointer"
|
||||||
|
title="Keluar Sesi Keycloak SSO"
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handlePkceLogin}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-500 text-white text-xs font-semibold shadow-md shadow-teal-600/30 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Key className="w-3.5 h-3.5" />
|
||||||
|
Login SSO Keycloak (PKCE)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={simrsUrl}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Home className="w-3.5 h-3.5" />
|
||||||
|
Ke SIMRS
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Activity, CheckCircle, Clock, AlertOctagon, Zap, ShieldAlert } from 'lucide-react';
|
||||||
|
import { LogStats } from '../types';
|
||||||
|
|
||||||
|
interface StatsOverviewProps {
|
||||||
|
stats: LogStats;
|
||||||
|
selectedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatsOverview: React.FC<StatsOverviewProps> = ({ stats, selectedDate }) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
||||||
|
{/* Total Event */}
|
||||||
|
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<span>Total Event</span>
|
||||||
|
<Activity className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-2xl font-bold text-white">{stats.total.toLocaleString()}</div>
|
||||||
|
<div className="text-[11px] text-cyan-400 mt-0.5 truncate">{selectedDate}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Sukses */}
|
||||||
|
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<span>Login Sukses</span>
|
||||||
|
<CheckCircle className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-2xl font-bold text-emerald-400">{stats.login_success.toLocaleString()}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
Gagal: <span className="text-rose-400 font-semibold">{stats.login_failed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session Expired */}
|
||||||
|
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<span>Session Expired</span>
|
||||||
|
<Clock className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-2xl font-bold text-amber-400">{stats.session_expired.toLocaleString()}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5">Navigasi Terhalang</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errors & Critical */}
|
||||||
|
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<span>Errors / Crit</span>
|
||||||
|
<AlertOctagon className="w-4 h-4 text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-2xl font-bold text-rose-400">{stats.errors.toLocaleString()}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
Warning: <span className="text-amber-400 font-semibold">{stats.warnings}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avg Response Time */}
|
||||||
|
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<span>Avg Response</span>
|
||||||
|
<Zap className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-2xl font-bold text-cyan-400">
|
||||||
|
{stats.avg_response_ms} <span className="text-xs font-normal text-slate-400">ms</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5">{stats.performance_count} Request</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brute Force Locked */}
|
||||||
|
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<span>IP Locked</span>
|
||||||
|
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-2xl font-bold text-rose-500">{stats.brute_force_locked}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5">Brute Force Detected</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html, body, #root {
|
||||||
|
background-color: #090d16;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(30, 41, 59, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(51, 65, 85, 0.6);
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card-hover {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.glass-card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(56, 189, 248, 0.4);
|
||||||
|
box-shadow: 0 15px 30px -5px rgba(14, 165, 233, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-box {
|
||||||
|
background: #050811;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #090d16;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #334155;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export interface LogContext {
|
||||||
|
ip_address?: string;
|
||||||
|
user_nip?: string;
|
||||||
|
request_uri?: string;
|
||||||
|
total_duration_ms?: number;
|
||||||
|
raw_log?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
channel: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
correlation_id: string;
|
||||||
|
context: LogContext;
|
||||||
|
_seq?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogStats {
|
||||||
|
total: number;
|
||||||
|
login_success: number;
|
||||||
|
login_failed: number;
|
||||||
|
session_expired: number;
|
||||||
|
errors: number;
|
||||||
|
warnings: number;
|
||||||
|
performance_count: number;
|
||||||
|
avg_response_ms: number;
|
||||||
|
brute_force_locked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LockedIP {
|
||||||
|
ip: string;
|
||||||
|
attempts: any[];
|
||||||
|
lockout_until: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupFile {
|
||||||
|
filename: string;
|
||||||
|
size_mb: number;
|
||||||
|
mtime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
login_time: string;
|
||||||
|
auth_type: string;
|
||||||
|
kc_server?: string;
|
||||||
|
kc_realm?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
source: string;
|
||||||
|
date: string;
|
||||||
|
time_start: string;
|
||||||
|
time_end: string;
|
||||||
|
channel: string;
|
||||||
|
level: string;
|
||||||
|
search: string;
|
||||||
|
limit: number;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5880,
|
||||||
|
host: true,
|
||||||
|
allowedHosts: true,
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
interval: 500,
|
||||||
|
ignored: ['**/logs/**', '**/node_modules/**', '**/dist/**', '**/.git/**'],
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5880',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user