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
1 change: 1 addition & 0 deletions forester/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ logs
.idea
.env
.env.devnet
.env.mainnet
*.json
!package.json
spawn.sh
Expand Down
3 changes: 1 addition & 2 deletions forester/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ RUN cargo build --release --package forester

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /app/config /app/static
RUN mkdir -p /app/config
COPY --from=builder /app/target/release/forester /usr/local/bin/forester
COPY --from=builder /app/forester/static /app/static
WORKDIR /app

ENTRYPOINT ["/usr/local/bin/forester"]
Expand Down
1 change: 1 addition & 0 deletions forester/dashboard/.env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NEXT_PUBLIC_FORESTER_API_URL=http://localhost:8080
4 changes: 4 additions & 0 deletions forester/dashboard/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.next/
out/
.env.local
26 changes: 26 additions & 0 deletions forester/dashboard/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM node:20-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
Comment on lines +17 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consolidate consecutive RUN instructions.

These two RUN commands can be merged into one layer, reducing image size slightly and addressing the Hadolint DL3059 hint.

♻️ Proposed fix
-RUN addgroup --system --gid 1001 nodejs
-RUN adduser --system --uid 1001 nextjs
+RUN addgroup --system --gid 1001 nodejs && \
+    adduser --system --uid 1001 nextjs
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
🧰 Tools
🪛 Hadolint (2.14.0)

[info] 18-18: Multiple consecutive RUN instructions. Consider consolidation.

(DL3059)

🤖 Prompt for AI Agents
In `@forester/dashboard/Dockerfile` around lines 17 - 18, Combine the two
consecutive Docker RUN layers into a single RUN to reduce image layers and
satisfy DL3059: replace the separate RUN addgroup --system --gid 1001 nodejs and
RUN adduser --system --uid 1001 nextjs with one RUN that executes both commands
(using shell chaining such as &&) so both addgroup and adduser run in the same
layer and preserve exit-on-failure behavior.

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
Comment on lines +23 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding a HEALTHCHECK instruction.

Both Trivy and Checkov flag the missing HEALTHCHECK. For a production dashboard container, a simple health check helps orchestrators (Docker Compose, ECS, K8s with Docker health probes) detect when the Next.js server is unresponsive.

🩺 Example HEALTHCHECK
 USER nextjs
 EXPOSE 3000
 ENV PORT=3000
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+  CMD ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/", "||", "exit", "1"]
 CMD ["node", "server.js"]

Note: node:20-alpine includes wget. Adjust the endpoint to a dedicated health route if one exists.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
USER nextjs
EXPOSE 3000
ENV PORT=3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/", "||", "exit", "1"]
CMD ["node", "server.js"]
🧰 Tools
🪛 Checkov (3.2.334)

[low] 1-26: Ensure that HEALTHCHECK instructions have been added to container images

(CKV_DOCKER_2)

🤖 Prompt for AI Agents
In `@forester/dashboard/Dockerfile` around lines 23 - 26, Add a Docker HEALTHCHECK
instruction to the Dockerfile to probe the running Next.js server (after the
existing USER nextjs / EXPOSE 3000 / ENV PORT=3000 / CMD ["node","server.js"]
lines): implement a lightweight HTTP probe (e.g., wget or curl against
http://localhost:3000/ or a dedicated /health route if available) with sensible
parameters (interval, timeout, start-period and retries) so the container
runtime can mark the container unhealthy when the server is unresponsive.

5 changes: 5 additions & 0 deletions forester/dashboard/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
6 changes: 6 additions & 0 deletions forester/dashboard/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};

export default nextConfig;
26 changes: 26 additions & 0 deletions forester/dashboard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "forester-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"swr": "^2.2.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0"
}
}
9 changes: 9 additions & 0 deletions forester/dashboard/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

export default config;
30 changes: 30 additions & 0 deletions forester/dashboard/src/app/compressible/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { useCompressible } from "@/hooks/useCompressible";
import { ErrorState } from "@/components/ErrorState";
import { CompressiblePanel } from "@/components/CompressiblePanel";

export default function CompressiblePage() {
const { data, error, isLoading } = useCompressible();

if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-400 text-sm">
Loading compressible status...
</div>
</div>
);
}

if (error || !data) {
return <ErrorState error={error} fallbackMessage="Failed to load compressible data" />;
}

return (
<div className="space-y-4">
<h2 className="text-xl font-bold">Compressible Accounts</h2>
<CompressiblePanel data={data} />
</div>
);
}
3 changes: 3 additions & 0 deletions forester/dashboard/src/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
25 changes: 25 additions & 0 deletions forester/dashboard/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { Sidebar } from "@/components/Sidebar";
import "./globals.css";

export const metadata: Metadata = {
title: "Forester Dashboard",
description: "Light Protocol Forester monitoring dashboard",
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-white text-gray-900 antialiased">
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-6 overflow-auto">{children}</main>
</div>
</body>
</html>
);
}
44 changes: 44 additions & 0 deletions forester/dashboard/src/app/metrics/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { useMetrics } from "@/hooks/useMetrics";
import { ErrorState } from "@/components/ErrorState";
import { MetricsPanel } from "@/components/MetricsPanel";

export default function MetricsPage() {
const { data: metrics, error, isLoading } = useMetrics();

if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-400 text-sm">Loading metrics...</div>
</div>
);
}

if (error || !metrics) {
return <ErrorState error={error} fallbackMessage="Failed to load metrics" />;
}

const isEmpty =
Object.keys(metrics.transactions_processed_total).length === 0 &&
Object.keys(metrics.forester_balances).length === 0;
Comment on lines +22 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider including queue_lengths in the empty-state check.

The emptiness check only looks at transactions_processed_total and forester_balances. If the API returns data solely in queue_lengths or transaction_rate, the page will still show the MetricsPanel — which is probably fine since MetricsPanel conditionally renders those sections. Just flagging in case the intent was to detect "truly no data at all."

🤖 Prompt for AI Agents
In `@forester/dashboard/src/app/metrics/page.tsx` around lines 22 - 24, The
empty-state check for isEmpty currently only inspects
metrics.transactions_processed_total and metrics.forester_balances; update it to
also consider metrics.queue_lengths (and optionally metrics.transaction_rate) so
that isEmpty truly reflects no data across all metric groups. Locate the isEmpty
declaration and include Object.keys(metrics.queue_lengths).length === 0 (and
Object.keys(metrics.transaction_rate).length === 0 if you want to treat
transaction_rate as contributing to non-empty) in the combined boolean
expression. Ensure you use the same metrics object keys (metrics.queue_lengths,
metrics.transaction_rate) and keep the logical AND semantics so the page only
treats it as empty when all relevant metric maps are empty.


return (
<div className="space-y-4">
<h2 className="text-xl font-bold">Metrics</h2>
{isEmpty ? (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center">
<p className="text-gray-500 text-sm">
No metrics data available.
</p>
<p className="text-gray-400 text-xs mt-2">
Configure --prometheus-url to query aggregated metrics, or connect
to a running forester instance.
</p>
</div>
) : (
<MetricsPanel metrics={metrics} />
)}
</div>
);
}
103 changes: 103 additions & 0 deletions forester/dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { useForesterStatus } from "@/hooks/useForesterStatus";
import { EpochCard } from "@/components/EpochCard";
import { ErrorState } from "@/components/ErrorState";
import { ForesterList } from "@/components/ForesterList";
import { QueuePressureChart } from "@/components/QueuePressureChart";
import { formatNumber } from "@/lib/utils";

export default function OverviewPage() {
const { data: status, error, isLoading } = useForesterStatus();

if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-400 text-sm">Loading forester status...</div>
</div>
);
}

if (error || !status) {
return <ErrorState error={error} fallbackMessage="Failed to load forester status" />;
}

const warnings: string[] = [];
if (status.active_epoch_foresters.length === 0) {
warnings.push("No foresters registered for the active epoch");
}
if (
status.slots_until_next_registration < 1000 &&
status.registration_epoch_foresters.length === 0
) {
warnings.push("Registration closing soon with no foresters registered");
}
Comment on lines +29 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Magic number 1000 for registration slot threshold.

This threshold controls when the "Registration closing soon" warning appears. Consider extracting it as a named constant to make the intent clearer and easier to tune:

+const REGISTRATION_WARNING_SLOT_THRESHOLD = 1000;
+
 if (
-    status.slots_until_next_registration < 1000 &&
+    status.slots_until_next_registration < REGISTRATION_WARNING_SLOT_THRESHOLD &&
     status.registration_epoch_foresters.length === 0
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
status.slots_until_next_registration < 1000 &&
status.registration_epoch_foresters.length === 0
) {
warnings.push("Registration closing soon with no foresters registered");
}
const REGISTRATION_WARNING_SLOT_THRESHOLD = 1000;
if (
status.slots_until_next_registration < REGISTRATION_WARNING_SLOT_THRESHOLD &&
status.registration_epoch_foresters.length === 0
) {
warnings.push("Registration closing soon with no foresters registered");
}
🤖 Prompt for AI Agents
In `@forester/dashboard/src/app/page.tsx` around lines 29 - 34, The literal 1000
should be replaced with a named constant to clarify intent and make tuning
easier: add a top-level constant like REGISTRATION_WARNING_SLOT_THRESHOLD (or
similar) in page.tsx and use it in the check inside the block that currently
references status.slots_until_next_registration (the if that also checks
status.registration_epoch_foresters.length). Replace the magic number with that
constant, choose a descriptive name and add a brief comment describing what the
threshold represents.


return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Overview</h2>
<span className="text-xs text-gray-400 font-mono">
Slot {formatNumber(status.slot)}
</span>
</div>

{warnings.map((w, i) => (
<div
key={i}
className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-sm text-amber-800"
>
{w}
</div>
))}
Comment on lines +45 to +52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider using the warning string as key instead of array index.

Since warning messages are unique strings derived deterministically from the status, they make better React keys than array indices. This is a minor nit — with a small, non-reorderable list it won't cause bugs, but it's slightly more idiomatic:

-      {warnings.map((w, i) => (
-        <div key={i}
+      {warnings.map((w) => (
+        <div key={w}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{warnings.map((w, i) => (
<div
key={i}
className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-sm text-amber-800"
>
{w}
</div>
))}
{warnings.map((w) => (
<div
key={w}
className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-sm text-amber-800"
>
{w}
</div>
))}
🤖 Prompt for AI Agents
In `@forester/dashboard/src/app/page.tsx` around lines 45 - 52, Replace the array
index key in the warnings.rendering (the warnings.map callback) with the warning
string itself: use the iteration value (the warning string variable passed as w)
as the React key in the div (ensuring it’s unique/deterministic), or fall back
to a stable derived id if you have any risk of duplicates; update the key prop
in the map inside the component that renders warnings (the warnings.map
callback) accordingly.


<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard label="Total Trees" value={status.total_trees} />
<StatCard label="Active Trees" value={status.active_trees} />
<StatCard label="Rolled Over" value={status.rolled_over_trees} />
<StatCard
label="Total Pending"
value={status.total_pending_items}
highlight={status.total_pending_items > 0}
/>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<EpochCard status={status} />
<QueuePressureChart stats={status.aggregate_queue_stats} />
</div>

<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ForesterList
title="Active Epoch Foresters"
foresters={status.active_epoch_foresters}
/>
<ForesterList
title="Registration Epoch Foresters"
foresters={status.registration_epoch_foresters}
/>
</div>
</div>
);
}

function StatCard({
label,
value,
highlight,
}: {
label: string;
value: number;
highlight?: boolean;
}) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-xs text-gray-500">{label}</div>
<div
className={`text-2xl font-semibold mt-1 ${highlight ? "text-amber-600" : "text-gray-900"}`}
>
{formatNumber(value)}
</div>
</div>
);
}
32 changes: 32 additions & 0 deletions forester/dashboard/src/app/trees/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { useForesterStatus } from "@/hooks/useForesterStatus";
import { ErrorState } from "@/components/ErrorState";
import { TreeTable } from "@/components/TreeTable";

export default function TreesPage() {
const { data: status, error, isLoading } = useForesterStatus();

if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-400 text-sm">Loading trees...</div>
</div>
);
}

if (error || !status) {
return <ErrorState error={error} fallbackMessage="Failed to load trees" />;
}

return (
<div className="space-y-4">
<h2 className="text-xl font-bold">Trees</h2>
<TreeTable
trees={status.trees}
foresters={status.active_epoch_foresters}
currentLightSlot={status.current_light_slot}
/>
</div>
);
}
Loading