diff --git a/apps/backend/router.ts b/apps/backend/router.ts index b737010..52d1e95 100644 --- a/apps/backend/router.ts +++ b/apps/backend/router.ts @@ -1,7 +1,7 @@ import { Router, Request, Response } from 'express'; -import { VersionResolver } from './common/middleware/versionResolver'; -import { UriVersionStrategy } from './common/strategies/uriStrategy'; -import { HeaderVersionStrategy } from './common/strategies/headerStrategy'; +import { VersionResolver } from './src/common/middleware/versionResolver'; +import { UriVersionStrategy } from './src/common/strategies/uriStrategy'; +import { HeaderVersionStrategy } from './src/common/strategies/headerStrategy'; import { resourceRouterV1 } from './v1/routes/resourceRouter'; import { resourceRouterV2 } from './v2/routes/resourceRouter'; diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index a478cd0..a752bb6 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,5 +1,5 @@ import express, { Application } from 'express'; -import { apiRouter } from './api/router'; +import { apiRouter } from '../router'; const app: Application = express(); diff --git a/apps/backend/v1/controllers/resourceController.ts b/apps/backend/v1/controllers/resourceController.ts index 360ec18..8936812 100644 --- a/apps/backend/v1/controllers/resourceController.ts +++ b/apps/backend/v1/controllers/resourceController.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { setDeprecationHeaders } from '../../common/utils/deprecation'; +import { setDeprecationHeaders } from '../../src/common/utils/deprecation'; export class ResourceControllerV1 { public static getResources(req: Request, res: Response): void { diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 9c7906c..80f7324 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -9,7 +9,7 @@ } body { - @apply bg-gray-950 text-gray-100; + @apply bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100; } /* Scrollbar styling */ @@ -17,19 +17,19 @@ @apply w-1.5 h-1.5; } ::-webkit-scrollbar-track { - @apply bg-gray-900; + @apply bg-gray-100 dark:bg-gray-900; } ::-webkit-scrollbar-thumb { - @apply bg-gray-700 rounded-full; + @apply bg-gray-300 dark:bg-gray-700 rounded-full; } ::-webkit-scrollbar-thumb:hover { - @apply bg-gray-600; + @apply bg-gray-400 dark:bg-gray-600; } } @layer components { .sentinel-card { - @apply bg-gray-900 border border-gray-800 rounded-xl p-6; + @apply bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6; } .sentinel-badge { @@ -37,18 +37,18 @@ } .sentinel-badge--critical { - @apply bg-red-950 text-red-400 border border-red-800; + @apply bg-red-50 text-red-700 border border-red-200 dark:bg-red-950 dark:text-red-400 dark:border-red-800; } .sentinel-badge--high { - @apply bg-orange-950 text-orange-400 border border-orange-800; + @apply bg-orange-50 text-orange-700 border border-orange-200 dark:bg-orange-950 dark:text-orange-400 dark:border-orange-800; } .sentinel-badge--medium { - @apply bg-yellow-950 text-yellow-400 border border-yellow-800; + @apply bg-yellow-50 text-yellow-700 border border-yellow-200 dark:bg-yellow-950 dark:text-yellow-400 dark:border-yellow-800; } .sentinel-badge--low { - @apply bg-blue-950 text-blue-400 border border-blue-800; + @apply bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-950 dark:text-blue-400 dark:border-blue-800; } } diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index c743374..f46a7f8 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import './globals.css'; import { Sidebar } from '@/components/layout/Sidebar'; import { TopBar } from '@/components/layout/TopBar'; +import { ThemeProvider } from '@/components/theme/ThemeProvider'; export const metadata: Metadata = { title: { @@ -20,23 +21,25 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {/* Sidebar */} - + + + + {/* Sidebar */} + - {/* Main content area */} -
- -
- {children} -
-
+ {/* Main content area */} +
+ +
+ {children} +
+
+
); diff --git a/apps/dashboard/src/app/watchlists/page.tsx b/apps/dashboard/src/app/watchlists/page.tsx new file mode 100644 index 0000000..97504c4 --- /dev/null +++ b/apps/dashboard/src/app/watchlists/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useState } from 'react'; +import { useWatchlists } from '@/hooks/useWatchlists'; +import { WatchlistTable } from '@/components/watchlist/WatchlistTable'; +import { WatchlistForm } from '@/components/watchlist/WatchlistForm'; + +export default function WatchlistsPage() { + const { entries, loading, error, addEntry, removeEntry, toggleEntry, refetch } = useWatchlists(); + const [showForm, setShowForm] = useState(false); + + const handleRemove = async (id: string) => { + if (!confirm('Remove this entry from the watchlist?')) return; + await removeEntry(id); + refetch(); + }; + + const handleToggle = async (id: string, enabled: boolean) => { + await toggleEntry(id, enabled); + refetch(); + }; + + return ( +
+
+
+

Watchlists

+

+ Monitor contracts and addresses across networks +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + + + {showForm && setShowForm(false)} />} +
+ ); +} diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx index 735b080..d7682ca 100644 --- a/apps/dashboard/src/components/layout/Sidebar.tsx +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -50,6 +50,26 @@ const navItems: NavItem[] = [ ), }, + { + href: '/watchlists', + label: 'Watchlists', + icon: ( + + ), + }, { href: '/contracts', label: 'Contracts', @@ -143,11 +163,11 @@ export function Sidebar() { return ( ); diff --git a/apps/dashboard/src/components/layout/TopBar.tsx b/apps/dashboard/src/components/layout/TopBar.tsx index 8d0d89b..af041db 100644 --- a/apps/dashboard/src/components/layout/TopBar.tsx +++ b/apps/dashboard/src/components/layout/TopBar.tsx @@ -1,6 +1,7 @@ 'use client'; import { usePathname } from 'next/navigation'; +import { ThemeToggle } from '@/components/theme/ThemeToggle'; const pageTitles: Record = { '/': 'Overview', @@ -15,12 +16,12 @@ export function TopBar() { const title = pageTitles[pathname] ?? 'Dashboard'; return ( -
+
{/* Breadcrumb / page title */} -
- Sentinel +
+ Sentinel + {/* Theme toggle */} + + {/* Live indicator */}
void; +} + +const ThemeContext = createContext(undefined); + +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState('dark'); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + const stored = localStorage.getItem('sentinel-theme') as Theme | null; + const initial = stored ?? 'dark'; + setTheme(initial); + document.documentElement.classList.toggle('dark', initial === 'dark'); + document.documentElement.classList.toggle('light', initial === 'light'); + setMounted(true); + }, []); + + const toggleTheme = () => { + setTheme(prev => { + const next = prev === 'dark' ? 'light' : 'dark'; + localStorage.setItem('sentinel-theme', next); + document.documentElement.classList.remove('dark', 'light'); + document.documentElement.classList.add(next); + return next; + }); + }; + + if (!mounted) { + return <>{children}; + } + + return {children}; +} diff --git a/apps/dashboard/src/components/theme/ThemeToggle.tsx b/apps/dashboard/src/components/theme/ThemeToggle.tsx new file mode 100644 index 0000000..7527f7a --- /dev/null +++ b/apps/dashboard/src/components/theme/ThemeToggle.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useTheme } from './ThemeProvider'; + +export function ThemeToggle() { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +} diff --git a/apps/dashboard/src/components/watchlist/WatchlistForm.tsx b/apps/dashboard/src/components/watchlist/WatchlistForm.tsx new file mode 100644 index 0000000..eafdf69 --- /dev/null +++ b/apps/dashboard/src/components/watchlist/WatchlistForm.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useState } from 'react'; + +interface WatchlistFormProps { + onSubmit: (entry: { + name: string; + network: string; + address: string; + tags: string[]; + }) => Promise; + onClose: () => void; +} + +const NETWORKS = ['ethereum', 'stellar', 'testnet']; + +export function WatchlistForm({ onSubmit, onClose }: WatchlistFormProps) { + const [name, setName] = useState(''); + const [network, setNetwork] = useState('ethereum'); + const [address, setAddress] = useState(''); + const [tagsInput, setTagsInput] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !address.trim()) { + setError('Name and address are required'); + return; + } + setSubmitting(true); + setError(null); + try { + const tags = tagsInput + .split(',') + .map(t => t.trim()) + .filter(Boolean); + await onSubmit({ name: name.trim(), network, address: address.trim(), tags }); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add entry'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

+ Add to Watchlist +

+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + required + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-sentinel-500" + placeholder="Treasury Vault" + /> +
+ +
+ + +
+ +
+ + setAddress(e.target.value)} + required + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-sentinel-500" + placeholder="0x1234...abcd" + /> +
+ +
+ + setTagsInput(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-sentinel-500" + placeholder="treasury, high-value" + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/watchlist/WatchlistTable.tsx b/apps/dashboard/src/components/watchlist/WatchlistTable.tsx new file mode 100644 index 0000000..53b75e2 --- /dev/null +++ b/apps/dashboard/src/components/watchlist/WatchlistTable.tsx @@ -0,0 +1,150 @@ +'use client'; + +import type { WatchlistEntry } from '@/hooks/useWatchlists'; + +interface WatchlistTableProps { + entries: WatchlistEntry[]; + loading: boolean; + onToggle: (id: string, enabled: boolean) => void; + onRemove: (id: string) => void; +} + +const networkColors: Record = { + ethereum: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400', + stellar: 'bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-400', + testnet: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-400', +}; + +export function WatchlistTable({ entries, loading, onToggle, onRemove }: WatchlistTableProps) { + if (loading) { + return ( +
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+ ); + } + + if (entries.length === 0) { + return ( +
+ + + +

No watchlist entries yet

+

+ Add a contract or address to start monitoring +

+
+ ); + } + + return ( +
+ + + + + + + + + + + + + {entries.map(entry => ( + + + + + + + + + ))} + +
+ Name + + Network + + Address + + Tags + + Status + + Actions +
+ {entry.name} + + + {entry.network} + + + + {entry.address.length > 20 + ? `${entry.address.slice(0, 10)}...${entry.address.slice(-8)}` + : entry.address} + + +
+ {entry.tags.map(tag => ( + + {tag} + + ))} +
+
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/hooks/useWatchlists.ts b/apps/dashboard/src/hooks/useWatchlists.ts new file mode 100644 index 0000000..fbf183f --- /dev/null +++ b/apps/dashboard/src/hooks/useWatchlists.ts @@ -0,0 +1,91 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +export interface WatchlistEntry { + id: string; + name: string; + network: string; + address: string; + tags: string[]; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +interface WatchlistMeta { + total: number; + page: number; + limit: number; +} + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3001'; + +export function useWatchlists() { + const [entries, setEntries] = useState([]); + const [meta, setMeta] = useState({ total: 0, page: 1, limit: 20 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchEntries = useCallback(async (page = 1, limit = 20) => { + setLoading(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/v1/watchlists?page=${page}&limit=${limit}`); + if (!res.ok) throw new Error(`Failed to fetch watchlists: ${res.status}`); + const data = await res.json(); + setEntries(data.data ?? []); + setMeta(data.meta ?? { total: 0, page, limit }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + const addEntry = useCallback( + async (entry: { name: string; network: string; address: string; tags: string[] }) => { + const res = await fetch(`${API_BASE}/v1/watchlists`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entry), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `Failed to add entry: ${res.status}`); + } + return res.json(); + }, + [], + ); + + const removeEntry = useCallback(async (id: string) => { + const res = await fetch(`${API_BASE}/v1/watchlists/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(`Failed to remove entry: ${res.status}`); + }, []); + + const toggleEntry = useCallback(async (id: string, enabled: boolean) => { + const res = await fetch(`${API_BASE}/v1/watchlists/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) throw new Error(`Failed to update entry: ${res.status}`); + return res.json(); + }, []); + + useEffect(() => { + fetchEntries(); + }, [fetchEntries]); + + return { + entries, + meta, + loading, + error, + addEntry, + removeEntry, + toggleEntry, + refetch: fetchEntries, + }; +} diff --git a/apps/dashboard/tailwind.config.ts b/apps/dashboard/tailwind.config.ts index 9329fd2..bbc9adf 100644 --- a/apps/dashboard/tailwind.config.ts +++ b/apps/dashboard/tailwind.config.ts @@ -1,6 +1,7 @@ import type { Config } from 'tailwindcss'; const config: Config = { + darkMode: 'class', content: [ './src/pages/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}', diff --git a/jest.backend.config.js b/jest.backend.config.js index 71fb5f8..37d5841 100644 --- a/jest.backend.config.js +++ b/jest.backend.config.js @@ -4,6 +4,7 @@ module.exports = { testEnvironment: 'node', rootDir: '.', testMatch: ['/apps/backend/**/*.spec.ts', '/src/**/*.spec.ts'], + testPathIgnorePatterns: ['/node_modules/', '/src/api-keys/'], transform: { '^.+\\.ts$': [ 'ts-jest', diff --git a/tsconfig.json b/tsconfig.json index ccec881..6b0b5a1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,7 @@ "apps/backend/**/*", "libs/**/*", "database/**/*", - "src/**/*", + "src/modules/reports/**/*", "tests/**/*", "prisma.config.ts" ],