為 ISO-27001 儀錶板加入 Groq 驅動的 AI 摘要及每日用量上限
Adding Groq‑Powered AI Summaries to the ISO‑27001 Dashboard with Daily Usage Limits
作者為 ISO-27001 儀錶板的各分析頁面整合 Groq,生成 AI 摘要,並加入每日 token 用量計數及上限。設定範例將每日 token 預算設為 2000,Groq API key 只在伺服器端讀取。摘要會儲存至資料庫並顯示在頁面,毋須完整重新載入頁面。
Adding Groq‑Powered AI Summaries to the ISO‑27001 Dashboard with Daily Usage Limits
TL;DR: Integrated server‑side Groq calls to generate daily AI summaries for each analysis page, added a usage‑counter middleware and UI components (AiButton, AiText). This keeps API keys secret, enforces a per‑day quota, and surfaces results without a full page reload.
The Problem
The ISO‑27001 dashboard lets auditors view controls, policies, and daily logs. A recurring request from the team was to get an AI‑generated summary of each analysis (/analisis/[id]) that highlighted gaps and suggested next steps.
Two technical blockers surfaced:
- Secure AI calls: The Groq API key was stored in the client bundle in an early prototype, causing CORS errors and exposing the secret.
- Unlimited usage: Groq charges per token, so we needed a hard daily limit per deployment to avoid runaway costs.
The symptom was a broken “Generate Summary” button that either threw a 401 Unauthorized (key exposed) or let anyone hammer the endpoint until the quota was exhausted, resulting in 429 Too Many Requests from Groq.
What I Tried First
My first attempt was a client‑side fetch from components/ai-button.tsx:
// early prototype (removed)
const resp = await fetch("https://api.groq.com/openai/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_GROQ_KEY}` },
body: JSON.stringify(payload),
});
Issues:
-
NEXT_PUBLIC_GROQ_KEYleaked into the bundle, visible in Chrome dev tools. - The request hit CORS restrictions because Groq does not allow the origin
localhost:3000. - No server‑side throttling; every click hit the API directly.
I also tried wrapping the fetch in a Next.js API route (pages/api/ai.ts) but hit the same quota‑blow problem because the route lacked any persistence of usage counts.
Result: the button either failed with a 401 or kept consuming tokens until the daily budget was spent, after which every call returned a 429.
The Implementation
1. Store Secrets Server‑Side
Added the Groq key to .env.example (never committed the real value):
+ GROQ_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
+ AI_DAILY_LIMIT=2000 # token budget per day
The production .env now contains the real key, and the code only accesses it via process.env.GROQ_API_KEY on the server.
2. Central AI Helper (lib/ai.ts)
Created a thin wrapper around the Groq client that also tracks daily usage:
// lib/ai.ts
import { Groq } from "groq-sdk";
import { db } from "@/lib/db";
export const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
export const aiEnabled = process.env.GROQ_API_KEY !== undefined;
export const dailyLimit = Number(process.env.AI_DAILY_LIMIT ?? 0);
/** Returns true if we have quota left for today */
export async function canUseAi(): Promise<boolean> {
const { used } = await db
.selectFrom("ai_usage")
.select("used")
.where("date", "=", new Date().toISOString().slice(0, 10))
.executeTakeFirst();
return (used ?? 0) < dailyLimit;
}
/** Increment usage counter */
export async function recordAiUsage(tokens: number) {
await db
.insertInto("ai_usage")
.values({
date: new Date().toISOString().slice(0, 10),
used: db.raw(`COALESCE(used,0) + ${tokens}`),
})
.onConflictDoUpdate({
target: ["date"],
set: { used: db.raw(`ai_usage.used + ${tokens}`) },
})
.execute();
}
The ai_usage table is defined in sql/schema.sql:
+ CREATE TABLE ai_usage (
+ date DATE PRIMARY KEY,
+ used INTEGER NOT NULL DEFAULT 0
+ );
3. Server Action (app/(app)/analisis/actions.ts)
Next.js 13 server actions let us call the AI from a form without an explicit API route:
// app/(app)/analisis/actions.ts
"use server";
import { redirect } from "next/navigation";
import { RuleError, str, withParam, errorMessage } from "@/lib/action-util";
import { aiEnabled, aiModel, buildContext, canUseAi, recordAiUsage, groq } from "@/lib/ai";
export async function generateSummary(id: string) {
if (!aiEnabled) throw new RuleError("AI not configured");
if (!(await canUseAi())) {
throw new RuleError("Daily AI quota exhausted");
}
const context = await buildContext(id);
const resp = await groq.chat.completions.create({
model: aiModel,
messages: [{ role: "system", content: context }],
});
const tokens = resp.usage?.total_tokens ?? 0;
await recordAiUsage(tokens);
// Store the result in the DB (pseudo code)
await db
.updateTable("analisis")
.set({ ai_summary: resp.choices[0].message.content })
.where("id", "=", id)
.execute();
redirect(`/analisis/${id}`);
}
Key points:
-
"use server"guarantees the function runs only on the server, keeping the API key safe. -
canUseAi()checks the daily limit before any request. -
recordAiUsage()updates theai_usagetable atomically. - Errors are thrown as
RuleErrorand surfaced by the UI component.
4. UI Components
components/ai-button.tsx
// components/ai-button.tsx
import { useTransition } from "react";
import { generateSummary } from "@/app/(app)/analisis/actions";
export function AiButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => startTransition(() => generateSummary(id))}
className="btn-primary"
>
{isPending ? "Generating…" : "Generate AI Summary"}
</button>
);
}
The button triggers the server action directly, avoiding a separate fetch call.
components/ai-text.tsx
// components/ai-text.tsx
export function AiText({ text }: { text: string }) {
return <p className="prose prose-sm mt-2">{text}</p>;
}
Used in the analysis detail page to render the stored summary.
5. Page Integration
Added the button and text to the analysis detail page:
diff
+ import { AiButton } from "@/components/ai-button";
+ import { AiText } from "@/components/ai-text";
...
+ <AiButton id={params.id} />
+ {analysis.ai_summary &&
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/iso27001-tablero` · 2026-10-17*
\#playadev #buildinpublic
來源:DEV Community · dev.to