Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ name: Build and Deploy to Registry
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

jobs:
Expand Down Expand Up @@ -40,7 +38,6 @@ jobs:
images: ${{ env.REGISTRY_URL }}/${{ github.event.repository.name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}

Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
pull_request:
branches: [main]
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4.2.2

# Keyring exports secrets to the environment (NPM_TOKEN for the private
# Font Awesome registry, plus the NEXT_PUBLIC_* build vars) so npm ci and
# next build can resolve deps and bake in public config. No registry push.
- name: Inject Keyring secrets
uses: aidenappl/keyring-actions@v1
with:
url: ${{ secrets.KEYRING_URL }}
access-key-id: ${{ secrets.KEYRING_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.KEYRING_SECRET_ACCESS_KEY }}

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 24

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Build
run: npm run build
1 change: 1 addition & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ async function start() {
}

if (process.env.KEYRING_URL) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Client } = require("@aidenappleby/keyring-js");
const client = new Client();
await client.injectEnv();
Expand Down
55 changes: 29 additions & 26 deletions src/app/api/monitor/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,32 @@ const UPSTREAM = (process.env.NEXT_PUBLIC_MONITOR_API_URL || "http://localhost:8

type Params = { path: string[] };

// Forward the Forta access token cookie so monitor-core can validate it.
// Forward the caller's Forta cookies verbatim so monitor-core (via go-forta) can
// validate the access token AND transparently refresh it: go-forta reads the
// forta-refresh-token cookie to mint a new pair when the access token expires.
// Forwarding only forta-access-token silently disables server-side refresh.
function upstreamHeaders(req: NextRequest): HeadersInit {
const token = req.cookies.get("forta-access-token")?.value;
return {
"Content-Type": "application/json",
...(token && { Cookie: `forta-access-token=${token}` }),
};
const headers: HeadersInit = { "Content-Type": "application/json" };
const cookie = req.headers.get("cookie");
if (cookie) headers["Cookie"] = cookie;
return headers;
}

// Relay the upstream status, body, and any Set-Cookie headers back to the
// browser. Propagating Set-Cookie is what delivers go-forta's refreshed
// forta-access-token / forta-refresh-token to the client; without it the
// browser keeps sending the expired token and every request 401s.
// getSetCookie() returns a proper string[]; headers.get("set-cookie") would
// comma-join multiple cookies and corrupt them.
function relay(upstream: Response, body: string): NextResponse {
const res = new NextResponse(body, {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
for (const cookie of upstream.headers.getSetCookie()) {
res.headers.append("set-cookie", cookie);
}
return res;
}

export async function GET(
Expand All @@ -24,11 +43,7 @@ export async function GET(
const url = `${UPSTREAM}/${path.join("/")}${search}`;

const upstream = await fetch(url, { headers: upstreamHeaders(req) });
const body = await upstream.text();
return new NextResponse(body, {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
return relay(upstream, await upstream.text());
}

export async function POST(
Expand All @@ -45,11 +60,7 @@ export async function POST(
headers: upstreamHeaders(req),
body,
});
const responseBody = await upstream.text();
return new NextResponse(responseBody, {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
return relay(upstream, await upstream.text());
}

export async function PUT(
Expand All @@ -66,11 +77,7 @@ export async function PUT(
headers: upstreamHeaders(req),
body,
});
const responseBody = await upstream.text();
return new NextResponse(responseBody, {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
return relay(upstream, await upstream.text());
}

export async function DELETE(
Expand All @@ -85,9 +92,5 @@ export async function DELETE(
method: "DELETE",
headers: upstreamHeaders(req),
});
const responseBody = await upstream.text();
return new NextResponse(responseBody, {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
return relay(upstream, await upstream.text());
}
2 changes: 0 additions & 2 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ export default function DashboardPage() {
}
};
loadDashboards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Load label values for variables
Expand Down Expand Up @@ -228,7 +227,6 @@ export default function DashboardPage() {
setSaveStatus("unsaved");
toast.error("Failed to save dashboard");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [widgets, variables, dashboardName, isNewDashboard, currentDashboard]);

const handleSaveAs = () => {
Expand Down
11 changes: 10 additions & 1 deletion src/app/live/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export default function LivePage() {
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<Event[]>([]);
const rafRef = useRef<number | null>(null);
const connectRef = useRef<() => void>(() => {});

// Keep refs in sync
useEffect(() => {
Expand Down Expand Up @@ -189,11 +190,15 @@ export default function LivePage() {
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000);
retryCountRef.current++;
reconnectTimerRef.current = setTimeout(() => {
if (!pausedRef.current) connect();
if (!pausedRef.current) connectRef.current();
}, delay);
};
}, [serviceFilter, levelFilter, nameFilter]);

useEffect(() => {
connectRef.current = connect;
}, [connect]);

const disconnect = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
Expand All @@ -209,6 +214,10 @@ export default function LivePage() {

// Auto-connect on mount and filter changes
useEffect(() => {
// connect() subscribes to the EventSource stream — a legitimate
// external-system effect. Its optimistic setStatus("connecting") is
// intentional and runs once per (re)connect, not a cascading render.
// eslint-disable-next-line react-hooks/set-state-in-effect
connect();
return () => {
if (reconnectTimerRef.current) {
Expand Down
1 change: 0 additions & 1 deletion src/app/notifications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
faCopy,
faChevronDown,
faChevronRight,
faCheck,
} from "@awesome.me/kit-c2d31bb269/icons/classic/solid";
import {
NotificationPolicy,
Expand Down
1 change: 0 additions & 1 deletion src/app/performance/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import { useState, useEffect, useCallback } from "react";
import toast from "react-hot-toast";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faSpinner,
Expand Down
4 changes: 1 addition & 3 deletions src/components/AutoRefresh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,9 @@ export function AutoRefresh({ onRefresh, loading }: AutoRefreshProps) {
useEffect(() => {
clearTimers();
if (intervalSeconds <= 0) {
setRemaining(0);
return;
}

setRemaining(intervalSeconds);

countdownRef.current = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) return intervalSeconds;
Expand Down Expand Up @@ -130,6 +127,7 @@ export function AutoRefresh({ onRefresh, loading }: AutoRefreshProps) {
key={item.label}
onClick={() => {
setIntervalSeconds(item.seconds);
setRemaining(item.seconds);
setOpen(false);
}}
className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
Expand Down
2 changes: 1 addition & 1 deletion src/components/EventTimeRangeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function EventTimeRangeChart({
} | null>(null);

// Chart dimensions
const padding = { top: 8, right: 8, bottom: 24, left: 8 };
const padding = useMemo(() => ({ top: 8, right: 8, bottom: 24, left: 8 }), []);
const chartHeight = 100;

// Always target ~60 bars regardless of zoom level for consistent density
Expand Down
11 changes: 11 additions & 0 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ async function fetchApi<T>(
});

if (!response.ok) {
// Session expired — the proxy relays go-forta's server-side refresh, so
// a 401 reaching here means even the refresh token is dead. Send the
// user through the Forta login flow instead of throwing a generic error.
if (response.status === 401 && typeof window !== "undefined") {
const apiUrl = (
process.env.NEXT_PUBLIC_MONITOR_API_URL || ""
).replace(/\/+$/, "");
window.location.href = `${apiUrl}/forta/login`;
throw new Error("session expired");
}

// Grant revoked — redirect to unauthorized page.
if (response.status === 403) {
try {
Expand Down
Loading