diff --git a/code/backend/cmd/server/main.go b/code/backend/cmd/server/main.go index becbeee..b8ada40 100644 --- a/code/backend/cmd/server/main.go +++ b/code/backend/cmd/server/main.go @@ -81,6 +81,7 @@ func main() { }) app.POST("/user", apiCfg.HandlerUser) app.POST("/log", auth.MiddlewareAuth(apiCfg.LogHandler)) + app.GET("/logs", auth.MiddlewareAuth(apiCfg.GetUserLogsHandler)) app.GET("/parsers", apiCfg.ListParsersHandler) app.GET("/parsers/:id", apiCfg.GetParserHandler) app.PATCH("/parsers/:id", apiCfg.UpdateParserHandler) diff --git a/code/backend/internal/api/get_user_logs.go b/code/backend/internal/api/get_user_logs.go new file mode 100644 index 0000000..028115a --- /dev/null +++ b/code/backend/internal/api/get_user_logs.go @@ -0,0 +1,84 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/EleventhHour-Projects/GoLogGo/code/backend/internal/auth" + "github.com/nottechdm/notnet/pkg/notnet" +) + +// GetUserLogsHandler returns paginated requests + stats for the authenticated user. +// GET /logs?page=1&limit=25&status=all +func (api *Config) GetUserLogsHandler(req *notnet.Request, res *notnet.Response, claims *auth.CustomClaims) error { + ctx := req.HTTPRequest.Context() + + // Parse query params + query := req.HTTPRequest.URL.Query() + page, _ := strconv.ParseInt(query.Get("page"), 10, 64) + if page < 1 { + page = 1 + } + limit, _ := strconv.ParseInt(query.Get("limit"), 10, 64) + if limit < 1 || limit > 100 { + limit = 25 + } + status := query.Get("status") // e.g. "completed", "pending", "" / "all" + + skip := (page - 1) * limit + + // Fetch stats + stats, err := api.Reqs.GetUserStats(ctx, claims.UserID) + if err != nil { + return res.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to fetch user stats", + }) + } + + // Fetch paginated requests + reqs, total, err := api.Reqs.GetUserRequests(ctx, claims.UserID, limit, skip, status) + if err != nil { + return res.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to fetch requests", + }) + } + + // For completed requests, fetch their parsed logs + type ReqRow struct { + ID string `json:"id"` + Payload string `json:"payload"` + Status string `json:"status"` + Attempts int `json:"attempts"` + CreatedAt interface{} `json:"createdAt"` + UpdatedAt interface{} `json:"updatedAt"` + Logs interface{} `json:"logs,omitempty"` + } + + rows := make([]ReqRow, 0, len(reqs)) + for _, r := range reqs { + row := ReqRow{ + ID: r.ID.Hex(), + Payload: string(r.Payload), + Status: string(r.Status), + Attempts: r.Attempts, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + if r.Status == "completed" { + logs, err := api.Reqs.GetLogsByReqID(ctx, r.ID) + if err == nil { + row.Logs = logs + } + } + rows = append(rows, row) + } + + return res.JSON(http.StatusOK, map[string]interface{}{ + "stats": stats, + "requests": rows, + "total": total, + "page": page, + "limit": limit, + "totalPages": (total + limit - 1) / limit, + }) +} diff --git a/code/backend/internal/database/get_user_logs.go b/code/backend/internal/database/get_user_logs.go new file mode 100644 index 0000000..d87a253 --- /dev/null +++ b/code/backend/internal/database/get_user_logs.go @@ -0,0 +1,135 @@ +package database + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// ReqWithLogs is a joined view of a request and its parsed logs. +type ReqWithLogs struct { + Req Req `json:"req"` + Logs []Log `json:"logs"` +} + +// GetUserRequests returns paginated requests belonging to a user, newest first. +// Pass a zero ObjectID to get requests across all users (admin use). +func (m *MongoDB) GetUserRequests( + ctx context.Context, + userID bson.ObjectID, + limit, skip int64, + status string, +) ([]Req, int64, error) { + col := m.Collections[CollectionRequests] + + filter := bson.M{"userId": userID} + if status != "" && status != "all" { + filter["status"] = ReqestStatus(status) + } + + total, err := col.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "createdAt", Value: -1}}). + SetLimit(limit). + SetSkip(skip) + + cursor, err := col.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var reqs []Req + for cursor.Next(ctx) { + var r Req + if err := cursor.Decode(&r); err != nil { + return nil, 0, err + } + reqs = append(reqs, r) + } + if reqs == nil { + reqs = []Req{} + } + return reqs, total, cursor.Err() +} + +// GetLogsByReqID returns all parsed log entries for a given request ID. +func (m *MongoDB) GetLogsByReqID(ctx context.Context, reqID bson.ObjectID) ([]Log, error) { + col := m.Collections[CollectionLogs] + + cursor, err := col.Find(ctx, bson.M{"reqId": reqID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var logs []Log + for cursor.Next(ctx) { + var l Log + if err := cursor.Decode(&l); err != nil { + return nil, err + } + logs = append(logs, l) + } + if logs == nil { + logs = []Log{} + } + return logs, cursor.Err() +} + +// GetUserStats returns counts of requests per status for a given user. +type UserStats struct { + Total int64 `json:"total"` + Pending int64 `json:"pending"` + Processing int64 `json:"processing"` + WaitingParser int64 `json:"waitingParser"` + Completed int64 `json:"completed"` + Failed int64 `json:"failed"` + LastActivityAt time.Time `json:"lastActivityAt"` +} + +func (m *MongoDB) GetUserStats(ctx context.Context, userID bson.ObjectID) (*UserStats, error) { + col := m.Collections[CollectionRequests] + + statuses := []ReqestStatus{ + StatusPending, StatusProcessing, StatusWaitingParser, StatusCompleted, StatusFailed, + } + + stats := &UserStats{} + for _, s := range statuses { + n, err := col.CountDocuments(ctx, bson.M{"userId": userID, "status": s}) + if err != nil { + return nil, err + } + switch s { + case StatusPending: + stats.Pending = n + case StatusProcessing: + stats.Processing = n + case StatusWaitingParser: + stats.WaitingParser = n + case StatusCompleted: + stats.Completed = n + case StatusFailed: + stats.Failed = n + } + stats.Total += n + } + + // Grab the most recent request timestamp + var latest struct { + CreatedAt time.Time `bson:"createdAt"` + } + opts := options.FindOne().SetSort(bson.D{{Key: "createdAt", Value: -1}}) + if err := col.FindOne(ctx, bson.M{"userId": userID}, opts).Decode(&latest); err == nil { + stats.LastActivityAt = latest.CreatedAt + } + + return stats, nil +} diff --git a/code/frontend/gologgo-client/app/api/logs/route.js b/code/frontend/gologgo-client/app/api/logs/route.js index f86422d..f734052 100644 --- a/code/frontend/gologgo-client/app/api/logs/route.js +++ b/code/frontend/gologgo-client/app/api/logs/route.js @@ -56,3 +56,51 @@ export async function POST(request) { ); } } + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const tokenCookie = cookieStore.get('tokenString'); + const token = tokenCookie?.value; + + if (!token) { + return NextResponse.json( + { error: 'Unauthorized: No token found' }, + { status: 401 } + ); + } + + // Forward query params (page, limit, status) to the Go backend + const { searchParams } = new URL(request.url); + const queryString = searchParams.toString(); + + const backendRes = await fetch( + `${BACKEND_URL}/logs${queryString ? `?${queryString}` : ''}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + // No caching — always fresh + cache: 'no-store', + } + ); + + if (!backendRes.ok) { + const errorText = await backendRes.text(); + return NextResponse.json( + { error: 'Failed to fetch logs from backend', details: errorText }, + { status: backendRes.status } + ); + } + + const data = await backendRes.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Error in GET /api/logs:', error); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} diff --git a/code/frontend/gologgo-client/app/layout.jsx b/code/frontend/gologgo-client/app/layout.jsx index c6296ba..71021c6 100644 --- a/code/frontend/gologgo-client/app/layout.jsx +++ b/code/frontend/gologgo-client/app/layout.jsx @@ -1,4 +1,5 @@ import { Analytics } from '@vercel/analytics/next' +import { Toaster } from 'sonner' import './globals.css' export const metadata = { @@ -40,6 +41,7 @@ export default function RootLayout({ children }) { {children} + {process.env.NODE_ENV === 'production' && } diff --git a/code/frontend/gologgo-client/components/log-dashboard.jsx b/code/frontend/gologgo-client/components/log-dashboard.jsx index 46e9308..edc3b6f 100644 --- a/code/frontend/gologgo-client/components/log-dashboard.jsx +++ b/code/frontend/gologgo-client/components/log-dashboard.jsx @@ -18,6 +18,7 @@ import { X, } from 'lucide-react' import { ThemeToggle } from '@/components/theme-toggle' +import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -66,14 +67,14 @@ export function LogDashboard() { }); if (res.ok) { setRawLogs(''); - alert('Logs successfully sent for processing!'); + toast.success('Logs successfully sent for processing!'); } else { const errorData = await res.json().catch(() => ({})); - alert(`Failed to process logs: ${errorData.error || 'Unknown error'}`); + toast.error(`Failed to process logs: ${errorData.error || 'Unknown error'}`); } } catch (err) { console.error(err); - alert('An error occurred while sending logs.'); + toast.error('An error occurred while sending logs.'); } finally { setIsProcessing(false); } diff --git a/code/frontend/gologgo-client/components/logs-explorer.jsx b/code/frontend/gologgo-client/components/logs-explorer.jsx index aa14b8b..24ff46c 100644 --- a/code/frontend/gologgo-client/components/logs-explorer.jsx +++ b/code/frontend/gologgo-client/components/logs-explorer.jsx @@ -1,21 +1,15 @@ 'use client' -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Sidebar } from '@/components/sidebar' import { useRouter } from 'next/navigation' import { ChevronDown, ChevronLeft, ChevronRight, - CircleHelp, - FileJson, - LayoutDashboard, - ListFilter, Menu, RefreshCw, Search, - TerminalSquare, - X, } from 'lucide-react' import { ThemeToggle } from '@/components/theme-toggle' import { Badge } from '@/components/ui/badge' @@ -23,52 +17,426 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +// ─── Status helpers ────────────────────────────────────────────────────────── -const baseLogs = [ - ['22:41:03', 'ERROR', 'auth-service', 'User authentication failed', 'Authentication', 'AuthParser', 'Parsed'], - ['22:40:15', 'INFO', 'payment-service', 'Payment completed successfully', 'Payment', 'PaymentParser', 'Parsed'], - ['22:39:42', 'WARN', 'nginx', 'Invalid request received', 'Unknown', '—', 'Unknown'], - ['22:38:51', 'INFO', 'user-service', 'New user registered', 'User Activity', 'UserParser', 'Parsed'], - ['22:37:22', 'ERROR', 'database', 'Connection timeout', 'PostgreSQL', 'PostgresParser', 'Parsed'], - ['22:36:14', 'INFO', 'auth-service', 'User session created', 'Authentication', 'AuthParser', 'Parsed'], - ['22:35:08', 'WARN', 'nginx', 'Request rate limit approaching', 'Nginx', 'NginxParser', 'Parsed'], - ['22:34:51', 'ERROR', 'payment-service', 'Payment gateway unavailable', 'Payment', 'PaymentParser', 'Parsed'], - ['22:33:27', 'INFO', 'user-service', 'Profile updated', 'User Activity', 'UserParser', 'Parsed'], - ['22:32:10', 'WARN', 'database', 'Slow query detected', 'PostgreSQL', 'PostgresParser', 'Parsed'], -] -const logs = Array(12).fill(baseLogs).flat().map((log, i) => [...log, i]) - -const options = { - Status: ['All', 'Parsed', 'Unknown'], - Level: ['All', 'INFO', 'WARN', 'ERROR'], - Service: ['All', 'auth-service', 'payment-service', 'nginx', 'user-service', 'database'], - Parser: ['All', 'AuthParser', 'NginxParser', 'PaymentParser', 'PostgresParser', 'Unknown'], - 'Time range': ['Last 15 minutes', 'Last hour', 'Last 24 hours', 'Last 7 days'], +const STATUS_META = { + pending: { label: 'Pending', color: 'text-amber-400', dot: 'bg-amber-400', pulse: true }, + processing: { label: 'Processing', color: 'text-blue-400', dot: 'bg-blue-400', pulse: true }, + waiting_parser: { label: 'Waiting Parser', color: 'text-violet-400', dot: 'bg-violet-400', pulse: true }, + completed: { label: 'Completed', color: 'text-emerald-400', dot: 'bg-emerald-400', pulse: false }, + failed: { label: 'Failed', color: 'text-destructive', dot: 'bg-destructive', pulse: false }, } +function StatusPill({ status }) { + const meta = STATUS_META[status] ?? { label: status, color: 'text-muted-foreground', dot: 'bg-muted-foreground', pulse: false } + return ( + + + {meta.label} + + ) +} +// ─── Filter select ──────────────────────────────────────────────────────────── -function FilterSelect({ label, value, onChange }) { - return +function FilterSelect({ label, value, onChange, options }) { + return ( + + ) } +// ─── Stat card ──────────────────────────────────────────────────────────────── + +function StatCard({ label, value, colorClass = 'text-foreground' }) { + return ( +
+

{label}

+

{value ?? '—'}

+
+ ) +} + +// ─── Main component ─────────────────────────────────────────────────────────── + export function LogsExplorer() { const router = useRouter() - const [search, setSearch] = useState('') - const [filters, setFilters] = useState({ Status: 'All', Level: 'All', Service: 'All', Parser: 'All', 'Time range': 'Last 24 hours' }) const [mobileOpen, setMobileOpen] = useState(false) - const [refreshed, setRefreshed] = useState(false) + + // Fetch state + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState(null) + const [stats, setStats] = useState(null) + const [requests, setRequests] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [totalPages, setTotalPages] = useState(1) + const [isAuthenticated, setIsAuthenticated] = useState(true) + + // Filter / pagination state + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('all') const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(10) - const filteredLogs = useMemo(() => { + const statusOptions = [ + { value: 'all', label: 'All Statuses' }, + { value: 'pending', label: 'Pending' }, + { value: 'processing', label: 'Processing' }, + { value: 'waiting_parser', label: 'Waiting Parser' }, + { value: 'completed', label: 'Completed' }, + { value: 'failed', label: 'Failed' }, + ] + + // ── Fetch data from /api/logs ────────────────────────────────────────────── + + const fetchLogs = useCallback(async (opts = {}) => { + const isRefresh = opts.refresh ?? false + if (isRefresh) setRefreshing(true) + else setLoading(true) + setError(null) + + try { + const params = new URLSearchParams({ + page: String(opts.page ?? currentPage), + limit: String(opts.limit ?? pageSize), + status: opts.status ?? statusFilter, + }) + + const res = await fetch(`/api/logs?${params}`) + + if (res.status === 401) { + setIsAuthenticated(false) + return + } + + if (!res.ok) throw new Error('Failed to fetch logs') + + const data = await res.json() + setStats(data.stats) + setRequests(data.requests ?? []) + setTotalCount(data.total ?? 0) + setTotalPages(data.totalPages ?? 1) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + setRefreshing(false) + } + }, [currentPage, pageSize, statusFilter, router]) + + // Initial load + useEffect(() => { fetchLogs() }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // Re-fetch on filter/page change (skip on mount, handled above) + useEffect(() => { + fetchLogs({ page: currentPage, limit: pageSize, status: statusFilter }) + }, [currentPage, pageSize, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleRefresh = () => { + fetchLogs({ refresh: true, page: currentPage, limit: pageSize, status: statusFilter }) + } + + const clearFilters = () => { + setSearch('') + setStatusFilter('all') setCurrentPage(1) - return logs.filter((log) => log.join(' ').toLowerCase().includes(search.toLowerCase()) && (filters.Status === 'All' || log[6] === filters.Status) && (filters.Level === 'All' || log[1] === filters.Level) && (filters.Service === 'All' || log[2] === filters.Service) && (filters.Parser === 'All' || log[5] === filters.Parser)) - }, [search, filters]) + } + + // Client-side search on the current page results (search within payload text) + const filtered = useMemo(() => { + if (!search.trim()) return requests + const q = search.toLowerCase() + return requests.filter((r) => { + const payloadMatch = r.payload?.toLowerCase().includes(q) + const idMatch = r.id?.toLowerCase().includes(q) + const statusMatch = r.status?.toLowerCase().includes(q) + const logsMatch = r.logs?.some?.((l) => JSON.stringify(l.normalizedLog).toLowerCase().includes(q)) + return payloadMatch || idMatch || statusMatch || logsMatch + }) + }, [requests, search]) + + // ── Format helpers ───────────────────────────────────────────────────────── + + const formatTime = (iso) => { + if (!iso) return '—' + try { + return new Date(iso).toLocaleString('en-IN', { + dateStyle: 'short', timeStyle: 'medium', hour12: false, + }) + } catch { return iso } + } + + const trimPayload = (payload, len = 80) => { + if (!payload) return '—' + const first = payload.split('\n')[0].trim() + return first.length > len ? first.slice(0, len) + '…' : first + } + + // ── Render ───────────────────────────────────────────────────────────────── + + if (!isAuthenticated) { + return ( +
+
+ +
+ + {mobileOpen && ( +
+
+ )} + +
+
+
+

+ Sign in to view your logs +

+ +

+ Sign in to access your submitted logs, processing status, + and detailed log information. +

- const totalPages = Math.ceil(filteredLogs.length / pageSize) - const paginatedLogs = filteredLogs.slice((currentPage - 1) * pageSize, currentPage * pageSize) - const clearFilters = () => { setSearch(''); setFilters({ Status: 'All', Level: 'All', Service: 'All', Parser: 'All', 'Time range': 'Last 24 hours' }) } + +
+
+
+
+ ) + } - return
{mobileOpen &&
}

Workspace

Logs

Browse and investigate incoming log events

Live
setSearch(event.target.value)} placeholder="Search logs..." className="h-9 border-input bg-background pl-9 text-sm" />
{(Object.keys(options)).map((label) => setFilters((current) => ({ ...current, [label]: value }))} />)}

All logs

24,521 events across 5 services

Auto-refresh on
TimeLevelServiceMessageFormatParserStatus{paginatedLogs.map((log, index) => router.push(`/logs/log-${2241 - index}`)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') router.push(`/logs/log-${2241 - index}`) }}>{log[0]}{log[1]}{log[2]}{log[3]}{log[4]}{log[5]}{log[6]}›)}
Showing {(currentPage - 1) * pageSize + 1}–{Math.min(currentPage * pageSize, filteredLogs.length)} of {filteredLogs.length} logs
Page {currentPage} of {totalPages || 1}
+ return ( +
+ {/* Sidebar */} +
+ {mobileOpen && ( +
+
+ )} + +
+
+ + {/* ── Header ── */} +
+
+ +
+

Workspace

+

Logs

+

Browse and investigate your submitted log events

+
+
+
+ + +
+
+ + {/* ── Stats ── */} +
+ + + + + + +
+ + {/* ── Filters ── */} +
+
+ + setSearch(e.target.value)} + placeholder="Search by payload, ID, or log fields…" + className="h-9 border-input bg-background pl-9 text-sm" + /> +
+
+ { setStatusFilter(v); setCurrentPage(1) }} + options={statusOptions} + /> + +
+
+ + {/* ── Table ── */} +
+
+
+

Your Log Requests

+

+ {loading ? 'Loading…' : `${totalCount.toLocaleString()} request${totalCount !== 1 ? 's' : ''} found`} +

+
+ {stats?.lastActivityAt && ( + + Last activity: {formatTime(stats.lastActivityAt)} + + )} +
+ + {error ? ( +
+ {error} —{' '} + +
+ ) : loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+

No logs found.

+ {(search || statusFilter !== 'all') && ( + + )} +
+ ) : ( +
+ + + + Submitted At + Payload Preview + Status + Attempts + Parsed Logs + + + + + {filtered.map((req) => ( + router.push(`/logs/${req.id}`)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') router.push(`/logs/${req.id}`) }} + > + + {formatTime(req.createdAt)} + + + {trimPayload(req.payload)} + + + + + + {req.attempts} + + + {req.logs != null ? ( + + {req.logs.length} log{req.logs.length !== 1 ? 's' : ''} + + ) : ( + — + )} + + › + + ))} + +
+
+ )} +
+ + {/* ── Pagination (kept as-is from original design) ── */} +
+ + {loading || filtered.length === 0 + ? 'No results' + : `Showing ${(currentPage - 1) * pageSize + 1}–${Math.min(currentPage * pageSize, totalCount)} of ${totalCount} requests`} + +
+ +
+ Page {currentPage} of {totalPages || 1} +
+ + +
+
+ +
+
+
+ ) } + diff --git a/code/frontend/gologgo-client/components/parsers-page.jsx b/code/frontend/gologgo-client/components/parsers-page.jsx index d0fc9f8..9e2333e 100644 --- a/code/frontend/gologgo-client/components/parsers-page.jsx +++ b/code/frontend/gologgo-client/components/parsers-page.jsx @@ -17,6 +17,7 @@ import { X } from 'lucide-react' import { ThemeToggle } from '@/components/theme-toggle' +import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' @@ -241,7 +242,7 @@ export function ParsersPage() { } setEditingParser(null) } catch (err) { - alert(`Error updating parser: ${err.message}`) + toast.error(`Error updating parser: ${err.message}`) } finally { setSavingEdit(false) } @@ -251,7 +252,7 @@ export function ParsersPage() { const handleCreateParser = async (e) => { e.preventDefault() if (!createForm.pattern) { - alert('Regex pattern is required') + toast.error('Regex pattern is required') return } setCreating(true) @@ -278,7 +279,7 @@ export function ParsersPage() { setCreateForm({ name: '', format: '', pattern: '', sampleLog: '', status: 'Active' }) fetchParsers(true) } catch (err) { - alert(`Error creating parser: ${err.message}`) + toast.error(`Error creating parser: ${err.message}`) } finally { setCreating(false) } diff --git a/code/frontend/gologgo-client/package-lock.json b/code/frontend/gologgo-client/package-lock.json index fa27c0f..44ec3ed 100644 --- a/code/frontend/gologgo-client/package-lock.json +++ b/code/frontend/gologgo-client/package-lock.json @@ -17,6 +17,7 @@ "react": "^19", "react-dom": "^19", "shadcn": "^4.11.0", + "sonner": "^2.0.8", "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.4.0" }, @@ -5135,6 +5136,22 @@ "npm": ">= 3.0.0" } }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/code/frontend/gologgo-client/package.json b/code/frontend/gologgo-client/package.json index 0f6c043..af58a2a 100644 --- a/code/frontend/gologgo-client/package.json +++ b/code/frontend/gologgo-client/package.json @@ -17,6 +17,7 @@ "react": "^19", "react-dom": "^19", "shadcn": "^4.11.0", + "sonner": "^2.0.8", "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.4.0" },