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)}")
|
logger.error(f"Monitor error: {str(e)}")
|
||||||
await asyncio.sleep(5)
|
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"""
|
"""Get recent log entries"""
|
||||||
return self.recent_logs[-lines:]
|
return self.recent_logs[-lines:]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import type {NextConfig} from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
images: {
|
||||||
|
domains: ['localhost'],
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {Metadata} from "next";
|
import type {Metadata} from "next";
|
||||||
import {Geist, Geist_Mono} from "next/font/google";
|
import {Geist, Geist_Mono} from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import {TrainingProvider} from "@/contexts/TrainingContext";
|
||||||
|
import {SamplesProvider} from "@/contexts/SamplesContext";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -27,7 +29,12 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
<TrainingProvider>
|
||||||
|
<SamplesProvider>
|
||||||
|
{children}
|
||||||
|
</SamplesProvider>
|
||||||
|
</TrainingProvider>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 (
|
return (
|
||||||
<div
|
<main className="min-h-screen p-4 bg-gray-50">
|
||||||
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)]">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
<header className="flex justify-between items-center">
|
||||||
<Image
|
<h1 className="text-3xl font-bold text-gray-900">Training Monitor</h1>
|
||||||
className="dark:invert"
|
<div className="text-sm text-gray-500">
|
||||||
src="/next.svg"
|
Live monitoring
|
||||||
alt="Next.js logo"
|
</div>
|
||||||
width={180}
|
</header>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<a
|
{/* Training Status Section */}
|
||||||
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"
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<h2 className="text-xl font-semibold mb-4 text-gray-500">Training Progress</h2>
|
||||||
target="_blank"
|
<Suspense fallback={<div>Loading training status...</div>}>
|
||||||
rel="noopener noreferrer"
|
<TrainingProgress/>
|
||||||
>
|
</Suspense>
|
||||||
<Image
|
</div>
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
{/* Latest Samples Section */}
|
||||||
alt="Vercel logomark"
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
||||||
width={20}
|
<div className="flex justify-between items-center mb-4">
|
||||||
height={20}
|
<h2 className="text-xl font-semibold text-gray-500">Latest Samples</h2>
|
||||||
/>
|
<span className="text-sm text-gray-500">Auto-updating</span>
|
||||||
Deploy now
|
</div>
|
||||||
</a>
|
<Suspense fallback={<div>Loading samples...</div>}>
|
||||||
<a
|
<SamplesGallery/>
|
||||||
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"
|
</Suspense>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</div>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
{/* 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]">
|
||||||
Read our docs
|
<div className="flex justify-between items-center mb-4">
|
||||||
</a>
|
<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>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
</main>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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