Add complete base frontend components
Signed-off-by: Felipe Cardoso <felipe.cardoso@hotmail.it>
This commit is contained in:
@@ -175,7 +175,7 @@ class TrainingMonitor:
|
||||
logger.error(f"Monitor error: {str(e)}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def get_log(self, lines: int = 50) -> List[str]:
|
||||
async def get_log(self, lines: int = 100) -> List[str]:
|
||||
"""Get recent log entries"""
|
||||
return self.recent_logs[-lines:]
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import type {NextConfig} from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
domains: ['localhost'],
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {Metadata} from "next";
|
||||
import {Geist, Geist_Mono} from "next/font/google";
|
||||
import "./globals.css";
|
||||
import {TrainingProvider} from "@/contexts/TrainingContext";
|
||||
import {SamplesProvider} from "@/contexts/SamplesContext";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -27,7 +29,12 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<TrainingProvider>
|
||||
<SamplesProvider>
|
||||
{children}
|
||||
</SamplesProvider>
|
||||
</TrainingProvider>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,102 +1,51 @@
|
||||
import Image from "next/image";
|
||||
import {TrainingProgress} from '@/components/TrainingProgress'
|
||||
import {SamplesGallery} from '@/components/SamplesGallery'
|
||||
import {LogViewer} from '@/components/LogViewer'
|
||||
import {Suspense} from 'react'
|
||||
|
||||
export default function Home() {
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
<main className="min-h-screen p-4 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<header className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Training Monitor</h1>
|
||||
<div className="text-sm text-gray-500">
|
||||
Live monitoring
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Training Status Section */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-500">Training Progress</h2>
|
||||
<Suspense fallback={<div>Loading training status...</div>}>
|
||||
<TrainingProgress/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Latest Samples Section */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-500">Latest Samples</h2>
|
||||
<span className="text-sm text-gray-500">Auto-updating</span>
|
||||
</div>
|
||||
<Suspense fallback={<div>Loading samples...</div>}>
|
||||
<SamplesGallery/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Log Viewer Section - Full Width */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6 lg:col-span-2 h-[400px]">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-500">Training Logs</h2>
|
||||
<span className="text-sm text-gray-500">Real-time updates</span>
|
||||
</div>
|
||||
<Suspense fallback={<div>Loading logs...</div>}>
|
||||
<LogViewer/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
36
frontend/src/components/LogViewer.tsx
Normal file
36
frontend/src/components/LogViewer.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
import {useTraining} from '@/contexts/TrainingContext'
|
||||
import {useEffect, useRef} from 'react'
|
||||
|
||||
export function LogViewer() {
|
||||
const {logs, isLoading} = useTraining()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
if (isLoading) return <div>Loading logs...</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="font-mono text-sm bg-gray-900 text-gray-100 p-4 rounded-lg flex-grow min-h-32 max-h-64 overflow-y-auto"
|
||||
>
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="whitespace-pre-wrap py-0.5 leading-tight"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<div className="text-gray-500 italic">No logs available</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/SamplesGallery.tsx
Normal file
28
frontend/src/components/SamplesGallery.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
import {useSamples} from '@/contexts/SamplesContext'
|
||||
import Image from 'next/image'
|
||||
|
||||
export function SamplesGallery() {
|
||||
const {samples, isLoading, error, refreshSamples} = useSamples()
|
||||
|
||||
if (isLoading) return <div>Loading samples...</div>
|
||||
if (error) return <div>Error loading samples: {error.message}</div>
|
||||
if (samples.length === 0) return <div>No samples available</div>
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{samples.map((sample) => (
|
||||
<div key={sample.filename}>
|
||||
<Image
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}${sample.url}`}
|
||||
alt={sample.filename}
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-cover rounded"
|
||||
/>
|
||||
<p className="text-sm mt-1">{sample.filename}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
frontend/src/components/TrainingProgress.tsx
Normal file
19
frontend/src/components/TrainingProgress.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
import {useTraining} from '@/contexts/TrainingContext'
|
||||
|
||||
export function TrainingProgress() {
|
||||
const {status, isLoading, error} = useTraining()
|
||||
|
||||
if (isLoading) return <div>Loading...</div>
|
||||
if (error) return <div>Error: {error.message}</div>
|
||||
if (!status) return <div>No training data available</div>
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-gray-500 font-semibold">Progress: {status.percentage.toFixed(2)}%</h3>
|
||||
<h3 className="text-gray-500 font-semibold">Step: {status.current_step} / {status.total_steps}</h3>
|
||||
<h3 className="text-gray-500 font-semibold">Loss: {status.loss.toFixed(6)}</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
frontend/src/contexts/SamplesContext.tsx
Normal file
52
frontend/src/contexts/SamplesContext.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
import {createContext, useContext, useEffect, useState} from 'react'
|
||||
import type {Sample} from '@/types/api'
|
||||
|
||||
interface SamplesContextType {
|
||||
samples: Sample[]
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
refreshSamples: () => Promise<void>
|
||||
}
|
||||
|
||||
const SamplesContext = createContext<SamplesContextType | undefined>(undefined)
|
||||
|
||||
export function SamplesProvider({children}: { children: React.ReactNode }) {
|
||||
const [samples, setSamples] = useState<Sample[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const fetchSamples = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/samples/list`)
|
||||
const data = await response.json()
|
||||
setSamples(data)
|
||||
} catch (err) {
|
||||
setError(err as Error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSamples()
|
||||
// Poll for new samples less frequently than training status
|
||||
const interval = setInterval(fetchSamples, 180000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SamplesContext.Provider value={{samples, isLoading, error, refreshSamples: fetchSamples}}>
|
||||
{children}
|
||||
</SamplesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSamples() {
|
||||
const context = useContext(SamplesContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useSamples must be used within a SamplesProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
102
frontend/src/contexts/TrainingContext.tsx
Normal file
102
frontend/src/contexts/TrainingContext.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
import {createContext, useContext, useEffect, useState} from 'react'
|
||||
import type {TrainingStatus} from '@/types/api'
|
||||
|
||||
interface TrainingContextType {
|
||||
status: TrainingStatus | null
|
||||
logs: string[]
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
const TrainingContext = createContext<TrainingContextType | undefined>(undefined)
|
||||
|
||||
export function TrainingProvider({children}: { children: React.ReactNode }) {
|
||||
const [status, setStatus] = useState<TrainingStatus | null>(null)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const filterLogsKeepLatestProgress = (logs: string[]): string[] => {
|
||||
const result: string[] = [];
|
||||
const progressLogMap = new Map<string, string>(); // Map to store the latest progress log for each progress key
|
||||
|
||||
logs.forEach((log) => {
|
||||
if (log.trim() === "") return; // Skip empty strings
|
||||
|
||||
// Detect if the log contains a progress bar
|
||||
const isProgressLog = /^(\S+):\s+\d+%\|/.test(log);
|
||||
|
||||
if (isProgressLog) {
|
||||
// Extract a unique key based on the identifier and the progress percentage
|
||||
const match = log.match(/^(\S+):\s+\d+%/); // Match the identifier (e.g., "ovs_bangel_001")
|
||||
const progressKey = match ? match[1] : null; // Extract the relevant progress key
|
||||
|
||||
if (progressKey) {
|
||||
progressLogMap.set(progressKey, log); // Update the map with the latest progress log for this key
|
||||
}
|
||||
} else {
|
||||
result.push(log); // Add non-progress logs immediately
|
||||
}
|
||||
});
|
||||
|
||||
// Add the latest progress logs to the result
|
||||
progressLogMap.forEach((log) => {
|
||||
result.push(log);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/training/status`)
|
||||
const data = await response.json()
|
||||
setStatus(data)
|
||||
setIsLoading(false)
|
||||
} catch (err) {
|
||||
setError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/training/log`)
|
||||
const data = await response.json()
|
||||
const filteredLogs = filterLogsKeepLatestProgress(data)
|
||||
setLogs(filteredLogs)
|
||||
setIsLoading(false)
|
||||
} catch (err) {
|
||||
setError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
|
||||
// Initial fetch
|
||||
fetchStatus()
|
||||
fetchLogs()
|
||||
|
||||
// Set up polling
|
||||
const interval = setInterval(fetchStatus, 10000)
|
||||
const intervalLogs = setInterval(fetchLogs, 10000)
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
clearInterval(intervalLogs)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TrainingContext.Provider value={{status, logs, isLoading, error}}>
|
||||
{children}
|
||||
</TrainingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTraining() {
|
||||
const context = useContext(TrainingContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useTraining must be used within a TrainingProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
22
frontend/src/types/api.ts
Normal file
22
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/types/api.ts
|
||||
export interface TrainingStatus {
|
||||
current_step: number;
|
||||
total_steps: number;
|
||||
loss: number;
|
||||
learning_rate: number;
|
||||
percentage: number;
|
||||
eta_seconds: number;
|
||||
steps_per_second: number;
|
||||
updated_at: string;
|
||||
source: string;
|
||||
source_path: string;
|
||||
}
|
||||
|
||||
export interface Sample {
|
||||
filename: string;
|
||||
url: string;
|
||||
created_at: string;
|
||||
source: string;
|
||||
source_path: string;
|
||||
size: number;
|
||||
}
|
||||
Reference in New Issue
Block a user