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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Example environment variables
DATABASE_URL=postgres://user:password@localhost:5432/cloud_costs?sslmode=disable
JWT_SECRET=your_jwt_secret_here
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_API_URL=http://localhost:8080
11 changes: 10 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,13 @@ Thumbs.db

# Build outputs
dist/
build/
build/
*.tsbuildinfo

# Claude / AI tooling (never commit)
.claude/
CLAUDE.md
.qodo/

# Local-only compose overrides
docker-compose.override.yml
168 changes: 96 additions & 72 deletions frontend/app/accounts/page.tsx

Large diffs are not rendered by default.

150 changes: 101 additions & 49 deletions frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import axios from 'axios'
import { getDashboardSummary, DashboardSummary } from '@/lib/api'
import { formatCurrency } from '@/lib/utils'
import { formatCurrency, withMinDuration } from '@/lib/utils'
import { Skeleton, StatCardSkeleton, ChartSkeleton, RecRowSkeleton } from '@/components/ui/Skeleton'
import { ThemeToggle } from '@/components/theme/ThemeToggle'
import { useTheme } from '@/components/theme/ThemeProvider'
import { DollarSign, Lightbulb, TrendingDown, Bell } from 'lucide-react'
import {
LineChart,
Line,
Expand All @@ -26,6 +30,11 @@ export default function DashboardPage() {
const router = useRouter()
const [data, setData] = useState<DashboardSummary | null>(null)
const [loading, setLoading] = useState(true)
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const gridColor = isDark ? '#334155' : '#e2e8f0'
const axisColor = isDark ? '#94a3b8' : '#64748b'
const tooltipBg = isDark ? '#1e293b' : '#ffffff'

useEffect(() => {
const token = localStorage.getItem('token')
Expand All @@ -36,7 +45,7 @@ export default function DashboardPage() {

const fetchData = async () => {
try {
const summary = await getDashboardSummary()
const summary = await withMinDuration(getDashboardSummary(), 1000)
setData(summary)
} catch (error) {
console.error('Failed to fetch dashboard data:', error)
Expand All @@ -52,80 +61,121 @@ export default function DashboardPage() {
fetchData()
}, [router])

if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
)
}

// Prepare pie chart data
const pieData = data?.cost_by_service
? Object.entries(data.cost_by_service).map(([name, value]) => ({ name, value }))
: []

return (
<div className="min-h-screen bg-slate-950">
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
{/* Header */}
<header className="bg-slate-900 border-b border-slate-800 sticky top-0 z-10">
<div className="flex items-center justify-between px-6 py-4">
<h1 className="text-xl font-bold text-white">Cloud Cost Optimization Hub</h1>
<div className="flex items-center gap-4">
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 sticky top-0 z-10">
<div className="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<h1 className="text-xl font-bold text-slate-900 dark:text-white">Cloud Cost Optimization Hub</h1>
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
<a
href="/accounts"
className="text-slate-400 hover:text-white transition"
className="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition"
>
Add Account
</a>
<ThemeToggle />
<button
type="button"
onClick={() => {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
}}
className="text-slate-400 hover:text-white transition"
className="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition"
>
Sign Out
</button>
</div>
</div>
</header>

<main className="p-6">
<main className="p-4 sm:p-6">
{loading ? (
<>
{/* Skeleton: Stat cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{Array.from({ length: 4 }).map((_, i) => (
<StatCardSkeleton key={i} />
))}
</div>
{/* Skeleton: Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<ChartSkeleton />
<ChartSkeleton />
</div>
{/* Skeleton: Top recommendations */}
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm transition-shadow hover:shadow-md hover:border-slate-300 dark:shadow-none dark:hover:border-slate-700">
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-800">
<Skeleton className="h-5 w-44" />
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{Array.from({ length: 3 }).map((_, i) => (
<RecRowSkeleton key={i} />
))}
</div>
</div>
</>
) : (
<div className="animate-fade-in">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-slate-900 rounded-lg p-6 border border-slate-800">
<p className="text-slate-400 text-sm mb-1">Total Cost (30d)</p>
<p className="text-3xl font-bold text-white">{formatCurrency(data?.total_cost || 0)}</p>
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 border border-slate-200 dark:border-slate-800 shadow-sm transition-shadow hover:shadow-md hover:border-slate-300 dark:shadow-none dark:hover:border-slate-700">
<div className="flex items-center gap-3 mb-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400">
<DollarSign className="h-5 w-5" />
</span>
<p className="text-slate-600 dark:text-slate-400 text-sm">Total Cost (30d)</p>
</div>
<p className="text-3xl font-bold text-slate-900 dark:text-white">{formatCurrency(data?.total_cost || 0)}</p>
</div>
<div className="bg-slate-900 rounded-lg p-6 border border-slate-800">
<p className="text-slate-400 text-sm mb-1">Active Recommendations</p>
<p className="text-3xl font-bold text-white">{data?.recommendations_count || 0}</p>
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 border border-slate-200 dark:border-slate-800 shadow-sm transition-shadow hover:shadow-md hover:border-slate-300 dark:shadow-none dark:hover:border-slate-700">
<div className="flex items-center gap-3 mb-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-100 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400">
<Lightbulb className="h-5 w-5" />
</span>
<p className="text-slate-600 dark:text-slate-400 text-sm">Active Recommendations</p>
</div>
<p className="text-3xl font-bold text-slate-900 dark:text-white">{data?.recommendations_count || 0}</p>
</div>
<div className="bg-slate-900 rounded-lg p-6 border border-slate-800">
<p className="text-slate-400 text-sm mb-1">Potential Savings</p>
<p className="text-3xl font-bold text-green-400">{formatCurrency(data?.potential_savings || 0)}</p>
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 border border-slate-200 dark:border-slate-800 shadow-sm transition-shadow hover:shadow-md hover:border-slate-300 dark:shadow-none dark:hover:border-slate-700">
<div className="flex items-center gap-3 mb-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400">
<TrendingDown className="h-5 w-5" />
</span>
<p className="text-slate-600 dark:text-slate-400 text-sm">Potential Savings</p>
</div>
<p className="text-3xl font-bold text-green-700 dark:text-green-400">{formatCurrency(data?.potential_savings || 0)}</p>
</div>
<div className="bg-slate-900 rounded-lg p-6 border border-slate-800">
<p className="text-slate-400 text-sm mb-1">Unread Alerts</p>
<p className="text-3xl font-bold text-yellow-400">{data?.unread_alerts || 0}</p>
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 border border-slate-200 dark:border-slate-800 shadow-sm transition-shadow hover:shadow-md hover:border-slate-300 dark:shadow-none dark:hover:border-slate-700">
<div className="flex items-center gap-3 mb-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
<Bell className="h-5 w-5" />
</span>
<p className="text-slate-600 dark:text-slate-400 text-sm">Unread Alerts</p>
</div>
<p className="text-3xl font-bold text-yellow-700 dark:text-yellow-400">{data?.unread_alerts || 0}</p>
</div>
</div>

{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Daily Cost Trend */}
<div className="bg-slate-900 rounded-lg p-6 border border-slate-800">
<h2 className="text-lg font-semibold text-white mb-4">Daily Cost Trend</h2>
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 border border-slate-200 dark:border-slate-800 shadow-sm transition-shadow hover:shadow-md hover:border-slate-300 dark:shadow-none dark:hover:border-slate-700">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">Daily Cost Trend</h2>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data?.daily_costs || []}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="date" stroke="#94a3b8" />
<YAxis stroke="#94a3b8" tickFormatter={(value) => `$${value}`} />
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
<XAxis dataKey="date" stroke={axisColor} />
<YAxis stroke={axisColor} tickFormatter={(value) => `$${value}`} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: 'none' }}
contentStyle={{ backgroundColor: tooltipBg, border: `1px solid ${gridColor}`, borderRadius: 8, color: axisColor }}
formatter={(value: number) => [`$${value.toFixed(2)}`, 'Cost']}
/>
<Line type="monotone" dataKey="cost" stroke="#3b82f6" strokeWidth={2} dot={false} />
Expand All @@ -135,8 +185,8 @@ export default function DashboardPage() {
</div>

{/* Cost by Service (Pie Chart) */}
<div className="bg-slate-900 rounded-lg p-6 border border-slate-800">
<h2 className="text-lg font-semibold text-white mb-4">Cost by Service</h2>
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 border border-slate-200 dark:border-slate-800 shadow-sm transition-shadow hover:shadow-md hover:border-slate-300 dark:shadow-none dark:hover:border-slate-700">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">Cost by Service</h2>
<div className="h-80">
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
Expand Down Expand Up @@ -169,28 +219,28 @@ export default function DashboardPage() {
</div>

{/* Recommendations Section */}
<div className="bg-slate-900 rounded-lg border border-slate-800">
<div className="px-6 py-4 border-b border-slate-800 flex justify-between items-center">
<h2 className="text-lg font-semibold text-white">Top Recommendations</h2>
<a href="/recommendations" className="text-blue-400 hover:text-blue-300 text-sm">
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm transition-shadow hover:shadow-md hover:border-slate-300 dark:shadow-none dark:hover:border-slate-700">
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Top Recommendations</h2>
<a href="/recommendations" className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm">
View All →
</a>
</div>
<div className="divide-y divide-slate-800">
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{data?.top_recommendations && data.top_recommendations.length > 0 ? (
data.top_recommendations.map((rec) => (
<div key={rec.id} className="px-6 py-4 flex items-center justify-between">
<div>
<h3 className="text-white font-medium">{rec.title}</h3>
<p className="text-slate-400 text-sm mt-1">
<h3 className="text-slate-900 dark:text-white font-medium">{rec.title}</h3>
<p className="text-slate-600 dark:text-slate-400 text-sm mt-1">
Type: {rec.type}
</p>
</div>
<div className="text-right">
<p className="text-green-400 font-semibold">
<p className="text-green-700 dark:text-green-400 font-semibold">
Save {formatCurrency(rec.potential_savings)}
</p>
<a href="/recommendations" className="mt-2 text-sm text-blue-400 hover:text-blue-300 inline-block">
<a href="/recommendations" className="mt-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 inline-block">
View Details
</a>
</div>
Expand All @@ -199,14 +249,16 @@ export default function DashboardPage() {
) : (
<div className="px-6 py-8 text-center text-slate-500">
No recommendations yet.{' '}
<a href="/recommendations" className="text-blue-400 hover:text-blue-300">
<a href="/recommendations" className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
Generate recommendations
</a>
</div>
)}
</div>
</div>
</div>
)}
</main>
</div>
)
}
}
33 changes: 31 additions & 2 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,28 @@
@tailwind utilities;

@layer base {
/* Light theme (default) */
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--radius: 0.5rem;
}

/* Dark theme */
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
Expand All @@ -19,15 +40,23 @@
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--radius: 0.5rem;
}
}

@layer base {
* {
@apply border-border;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-background text-foreground;
}
}
}

@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
15 changes: 12 additions & 3 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { ThemeProvider } from '@/components/theme/ThemeProvider'

const inter = Inter({ subsets: ['latin'] })

Expand All @@ -9,14 +10,22 @@ export const metadata: Metadata = {
description: 'Multi-cloud cost observability and optimization platform',
}

// Runs before paint to apply the saved theme and prevent a flash of the wrong theme.
const noFlashScript = `(function(){try{var t=localStorage.getItem('theme')||'system';var d=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',d);}catch(e){}})();`

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: noFlashScript }} />
</head>
<body className={inter.className}>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
}
Loading
Loading