3 Commits
7 changed files with 113 additions and 19 deletions

No files matched your search

+1 -1
View File
@@ -15,6 +15,6 @@ EXPOSE 3001
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/api/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))" CMD node -e "require('http').get('http://127.0.0.1:3001/api/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))"
CMD ["node", "server.js"] CMD ["node", "server.js"]
+1 -1
View File
@@ -10,6 +10,6 @@ COPY backend/ .
EXPOSE 3001 EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD bun -e "require('http').get('http://localhost:3001/api/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))" CMD bun -e "require('http').get('http://127.0.0.1:3001/api/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))"
CMD ["bun", "run", "server.js"] CMD ["bun", "run", "server.js"]
+5 -5
View File
@@ -7,7 +7,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const { Summary } = require('../../models/Schemas'); const { Summary } = require('../../models/Schemas');
const { getTimeFilter } = require('./helpers'); const { getTimeFilter, isKnownSite } = require('./helpers');
// GET /api/dashboard/agents/uptime // GET /api/dashboard/agents/uptime
router.get('/agents/uptime', async (req, res) => { router.get('/agents/uptime', async (req, res) => {
@@ -35,7 +35,7 @@ router.get('/agents/uptime', async (req, res) => {
req.user?.role === 'EXECUTIVE' || req.user?.role === 'EXECUTIVE' ||
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all') { if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all' && isKnownSite(requestedSiteUuid)) {
query.site_uuid = { $in: [requestedSiteUuid, 'global'] }; query.site_uuid = { $in: [requestedSiteUuid, 'global'] };
} else if (!isGlobalUser && req.user?.site_uuid) { } else if (!isGlobalUser && req.user?.site_uuid) {
query.site_uuid = { $in: [req.user.site_uuid, 'global'] }; query.site_uuid = { $in: [req.user.site_uuid, 'global'] };
@@ -80,7 +80,7 @@ router.get('/agents', async (req, res) => {
const isGlobalUser = effectiveRole === 'SUPER_ADMIN' || effectiveRole === 'EXECUTIVE'; const isGlobalUser = effectiveRole === 'SUPER_ADMIN' || effectiveRole === 'EXECUTIVE';
const requestedSiteUuid = req.headers['x-backone-site-uuid']; const requestedSiteUuid = req.headers['x-backone-site-uuid'];
if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all') { if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all' && isKnownSite(requestedSiteUuid)) {
query.site_uuid = { $in: [requestedSiteUuid, 'global'] }; query.site_uuid = { $in: [requestedSiteUuid, 'global'] };
} else if (effectiveRole === 'TENANT_ADMIN') { } else if (effectiveRole === 'TENANT_ADMIN') {
query.site_uuid = { $in: [req.user.site_uuid, 'global'] }; query.site_uuid = { $in: [req.user.site_uuid, 'global'] };
@@ -117,7 +117,7 @@ router.get('/agents/storage', async (req, res) => {
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
let siteUuid = null; let siteUuid = null;
if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all') { if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all' && isKnownSite(requestedSiteUuid)) {
siteUuid = requestedSiteUuid; siteUuid = requestedSiteUuid;
} else if (!isGlobalUser && req.user?.site_uuid) { } else if (!isGlobalUser && req.user?.site_uuid) {
siteUuid = req.user.site_uuid; siteUuid = req.user.site_uuid;
@@ -181,7 +181,7 @@ router.get('/agents/list', async (req, res) => {
const effectiveRole = user?._originalRole || user?.role; const effectiveRole = user?._originalRole || user?.role;
const isGlobalUser = effectiveRole === 'SUPER_ADMIN' || effectiveRole === 'EXECUTIVE'; const isGlobalUser = effectiveRole === 'SUPER_ADMIN' || effectiveRole === 'EXECUTIVE';
if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all') { if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all' && isKnownSite(requestedSiteUuid)) {
filter.site_uuid = { $in: [requestedSiteUuid, 'global'] }; filter.site_uuid = { $in: [requestedSiteUuid, 'global'] };
} else if (user?.site_uuid) { } else if (user?.site_uuid) {
filter.site_uuid = { $in: [user.site_uuid, 'global'] }; filter.site_uuid = { $in: [user.site_uuid, 'global'] };
+16 -2
View File
@@ -1,5 +1,18 @@
const { CustomDeviceLabel, Flow } = require('../../models/Schemas'); const { CustomDeviceLabel, Flow } = require('../../models/Schemas');
// Valid tenant site UUIDs (from env). A global user requesting a site that is not
// in this list (e.g. a stale 'test-site' from the account's site_uuid column) must
// NOT silently filter everything out — treat it as "all sites" instead.
const KNOWN_SITES = (process.env.BACKONE_SITE_UUIDS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
function isKnownSite(siteUuid) {
// Empty env => no known-site list configured, keep legacy behavior (filter anything).
return KNOWN_SITES.length === 0 || KNOWN_SITES.includes(siteUuid);
}
function getTimeFilter(req) { function getTimeFilter(req) {
// Explicit calendar date range (from the per-page date picker) takes priority // Explicit calendar date range (from the per-page date picker) takes priority
// over the global sidebar time range. Both dates are interpreted as WIB (UTC+7) // over the global sidebar time range. Both dates are interpreted as WIB (UTC+7)
@@ -40,7 +53,7 @@ function getBaseFilter(req, timeFilter = null) {
req.user?.role === 'EXECUTIVE' || req.user?.role === 'EXECUTIVE' ||
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all') { if (isGlobalUser && requestedSiteUuid && requestedSiteUuid !== 'all' && isKnownSite(requestedSiteUuid)) {
filter.site_uuid = { $in: [requestedSiteUuid, 'global'] }; filter.site_uuid = { $in: [requestedSiteUuid, 'global'] };
} else if (!isGlobalUser && req.user?.site_uuid) { } else if (!isGlobalUser && req.user?.site_uuid) {
filter.site_uuid = { $in: [req.user.site_uuid, 'global'] }; filter.site_uuid = { $in: [req.user.site_uuid, 'global'] };
@@ -99,5 +112,6 @@ module.exports = {
getTimeFilter, getTimeFilter,
getBaseFilter, getBaseFilter,
getCustomLabelsMap, getCustomLabelsMap,
topFlowField topFlowField,
isKnownSite
}; };
+11 -6
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getApiBase, resetApiBase } from '@/lib/api-base';
export async function GET(req: NextRequest, props: { params: Promise<{ route: string[] }> }) { export async function GET(req: NextRequest, props: { params: Promise<{ route: string[] }> }) {
return handleProxy(req, props); return handleProxy(req, props);
@@ -27,9 +28,10 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
const url = new URL(req.url); const url = new URL(req.url);
// INTERNAL_API_URL is read at RUNTIME (not baked at build time like NEXT_PUBLIC_*) // INTERNAL_API_URL is read at RUNTIME (not baked at build time like NEXT_PUBLIC_*).
// This allows Docker Swarm / Portainer to set the backend URL dynamically // getApiBase() probes configured URLs and transparently falls back to the stable
const localBase = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001'; // Swarm service alias if the configured one (e.g. task hostname ...-0) is unreachable.
const localBase = await getApiBase();
const destinationUrl = `${localBase}/api/${route}${url.search}`; const destinationUrl = `${localBase}/api/${route}${url.search}`;
// Prepare headers // Prepare headers
@@ -67,7 +69,7 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
headers, headers,
body: body, body: body,
cache: 'no-store', // Prevent Next.js from aggressively caching the backend response cache: 'no-store', // Prevent Next.js from aggressively caching the backend response
// @ts-ignore // @ts-expect-error - `duplex` is required for streaming bodies in older fetch typings
duplex: hasBody ? 'half' : undefined, duplex: hasBody ? 'half' : undefined,
}); });
@@ -93,8 +95,11 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
} }
return nextResponse; return nextResponse;
} catch (error: any) { } catch (error) {
// Backend may have been redeployed (task hostname changed) — re-resolve next request.
resetApiBase();
console.error('[Proxy Error]', error); console.error('[Proxy Error]', error);
return NextResponse.json({ ok: false, message: error.message }, { status: 500 }); const message = error instanceof Error ? error.message : 'Unknown proxy error';
return NextResponse.json({ ok: false, message }, { status: 500 });
} }
} }
+4 -4
View File
@@ -45,8 +45,9 @@ export function Sidebar({ isMobile = false, onClose }: SidebarProps) {
// Role-aware site UUID logic: // Role-aware site UUID logic:
// - Global roles (SUPER_ADMIN, EXECUTIVE, SOC_ANALYST, ENGINEER): // - Global roles (SUPER_ADMIN, EXECUTIVE, SOC_ANALYST, ENGINEER):
// Prioritaskan pilihan yang TERSIMPAN di localStorage. // Prioritaskan pilihan yang TERSIMPAN di localStorage.
// Hanya set dari DB jika belum ada pilihan tersimpan. // Jangan pakai site_uuid dari DB — untuk role global nilainya bisa
// Ini agar site switcher tidak reset ke SIAB setiap page reload. // placeholder (mis. 'test-site') yang bukan site asli, sehingga
// dashboard ikut terfilter kosong. Default yang aman adalah 'all'.
// - Restricted roles (TENANT_ADMIN, AGENT_VIEWER, COMPANY_*): // - Restricted roles (TENANT_ADMIN, AGENT_VIEWER, COMPANY_*):
// Selalu pakai site_uuid dari DB (mereka tidak bisa ganti site). // Selalu pakai site_uuid dari DB (mereka tidak bisa ganti site).
const globalRoles = ['SUPER_ADMIN', 'EXECUTIVE', 'SOC_ANALYST', 'ENGINEER']; const globalRoles = ['SUPER_ADMIN', 'EXECUTIVE', 'SOC_ANALYST', 'ENGINEER'];
@@ -54,8 +55,7 @@ export function Sidebar({ isMobile = false, onClose }: SidebarProps) {
const storedSiteUuid = localStorage.getItem('backone_site_uuid'); const storedSiteUuid = localStorage.getItem('backone_site_uuid');
if (isGlobalRole) { if (isGlobalRole) {
// Pakai localStorage jika sudah ada, fallback ke DB, fallback ke SIAB default const siteToUse = storedSiteUuid || 'all';
const siteToUse = storedSiteUuid || data.user.site_uuid || 'all';
localStorage.setItem('backone_site_uuid', siteToUse); localStorage.setItem('backone_site_uuid', siteToUse);
setSelectedSite(siteToUse); setSelectedSite(siteToUse);
} else if (data.user.site_uuid) { } else if (data.user.site_uuid) {
+75
View File
@@ -0,0 +1,75 @@
// src/lib/api-base.ts
// Server-only helper: resolve the backend API base URL at runtime.
//
// Why:
// The frontend proxies /api/* to the backend via NEXT_PUBLIC_API_URL /
// INTERNAL_API_URL. In Docker Swarm deployments these may point to a
// task-instance hostname like `backone-inspect-backend-bun-0:3001`. When the
// Swarm service is redeployed the task index can change (`-0` -> `-1`), orphaning
// the configured URL and breaking every proxied request ("fetch failed").
//
// Solution:
// Probe candidates in order and cache the first healthy one. Also auto-derive
// the stable service alias by stripping a trailing `-<digits>` suffix, so a
// stale `-0` URL transparently recovers to the plain service DNS name.
let cachedBase: string | null = null;
function pushUnique(list: string[], value?: string | null) {
if (!value) return;
const clean = value.replace(/\/+$/, '');
if (clean && !list.includes(clean)) list.push(clean);
}
function buildCandidates(): string[] {
const list: string[] = [];
pushUnique(list, process.env.INTERNAL_API_URL);
pushUnique(list, process.env.NEXT_PUBLIC_API_URL);
// Derive stable Swarm service alias (strip task index: ...-0 -> ...)
for (const base of [...list]) {
try {
const u = new URL(base);
const strippedHost = u.host.replace(/-\d+$/, '');
if (strippedHost && strippedHost !== u.host) {
pushUnique(list, `${u.protocol}//${strippedHost}`);
}
} catch {
// ignore malformed URLs
}
}
pushUnique(list, 'http://127.0.0.1:3001');
return list;
}
async function isHealthy(base: string): Promise<boolean> {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 2500);
const res = await fetch(`${base}/api/health`, { signal: ctrl.signal });
clearTimeout(timer);
return res.ok;
} catch {
return false;
}
}
export async function getApiBase(): Promise<string> {
if (cachedBase) return cachedBase;
for (const base of buildCandidates()) {
if (await isHealthy(base)) {
cachedBase = base;
return base;
}
}
// Fallback: use the first configured candidate even if the probe failed.
cachedBase = buildCandidates()[0] || 'http://127.0.0.1:3001';
return cachedBase;
}
export function resetApiBase() {
cachedBase = null;
}