diff --git a/cmd/pnf/main.go b/cmd/pnf/main.go index 94e8575..9bfc03a 100644 --- a/cmd/pnf/main.go +++ b/cmd/pnf/main.go @@ -367,7 +367,7 @@ func tuiCmd(args []string) { listener.Close() // Start local server in background, with web UI so 'o' can open it in a browser - srv, err := server.New(*logdir, web.DistFS()) + srv, err := server.New(*logdir, web.DistFS(), "", "") if err != nil { fmt.Fprintf(os.Stderr, "Error creating server: %v\n", err) os.Exit(1) @@ -420,8 +420,17 @@ func serveCmd(args []string) { port := fs.Int("port", 6767, "Port to listen on") host := fs.String("host", "localhost", "Host to bind to") openBrowser := fs.Bool("open", true, "Open browser automatically") + remoteURL := fs.String("url", "", "Remote API base URL (fallback: P95_URL)") + apiKey := fs.String("api-key", "", "Remote API key (fallback: P95_API_KEY)") fs.Parse(args) + if *remoteURL == "" { + *remoteURL = os.Getenv("P95_URL") + } + if *apiKey == "" { + *apiKey = os.Getenv("P95_API_KEY") + } + if *logdir == "" { *logdir = defaultLogDir() } @@ -436,7 +445,7 @@ func serveCmd(args []string) { webFS := web.DistFS() // Create server - srv, err := server.New(*logdir, webFS) + srv, err := server.New(*logdir, webFS, *remoteURL, *apiKey) if err != nil { log.Fatalf("Failed to create server: %v", err) } diff --git a/internal/domain/sweep.go b/internal/domain/sweep.go new file mode 100644 index 0000000..550d3db --- /dev/null +++ b/internal/domain/sweep.go @@ -0,0 +1,55 @@ +package domain + +import "time" + +// SweepStatus represents the status of a hyperparameter sweep +type SweepStatus string + +const ( + SweepStatusRunning SweepStatus = "running" + SweepStatusCompleted SweepStatus = "completed" + SweepStatusFailed SweepStatus = "failed" + SweepStatusStopped SweepStatus = "stopped" +) + +// ParameterSpec defines a hyperparameter search space +type ParameterSpec struct { + Name string `json:"name"` + Type string `json:"type"` // uniform, log_uniform, int, categorical + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + Values []any `json:"values,omitempty"` +} + +// SearchSpace contains the hyperparameter search space +type SearchSpace struct { + Parameters []ParameterSpec `json:"parameters"` +} + +// EarlyStoppingConfig configures early stopping for a sweep +type EarlyStoppingConfig struct { + Method string `json:"method"` + MinSteps int `json:"min_steps"` + Warmup int `json:"warmup"` +} + +// Sweep represents a hyperparameter sweep +type Sweep struct { + ID string `json:"id"` + Name string `json:"name"` + Status SweepStatus `json:"status"` + Method string `json:"method"` // random, grid + MetricName string `json:"metric_name"` + MetricGoal string `json:"metric_goal"` // minimize, maximize + SearchSpace SearchSpace `json:"search_space"` + Config map[string]any `json:"config,omitempty"` + MaxRuns *int `json:"max_runs,omitempty"` + EarlyStopping *EarlyStoppingConfig `json:"early_stopping,omitempty"` + BestRunID *string `json:"best_run_id,omitempty"` + BestValue *float64 `json:"best_value,omitempty"` + RunCount int `json:"run_count"` + GridIndex int `json:"grid_index"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/server/server.go b/internal/server/server.go index f670739..c33ce96 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/go-chi/chi/v5" @@ -20,6 +21,7 @@ import ( "github.com/ninetyfive/p95/internal/domain" "github.com/ninetyfive/p95/internal/storage" "github.com/ninetyfive/p95/internal/storage/file" + "github.com/ninetyfive/p95/pkg/client" ) // Server represents the local HTTP server. @@ -29,18 +31,36 @@ type Server struct { server *http.Server webFS fs.FS activeRun string // Currently active run ID for UI navigation + + remoteClient *client.Client // nil when no remote configured + + mu sync.RWMutex + runSources map[string]string // runID → "local" | "remote" + sweepSources map[string]sweepSourceEntry // sweepID → source info +} + +type sweepSourceEntry struct { + source string // "remote" + teamSlug string + appSlug string } // New creates a new local server instance. -func New(logdir string, webFS fs.FS) (*Server, error) { +func New(logdir string, webFS fs.FS, remoteURL string, remoteAPIKey string) (*Server, error) { store, err := file.New(logdir) if err != nil { return nil, fmt.Errorf("failed to create storage: %w", err) } s := &Server{ - storage: store, - webFS: webFS, + storage: store, + webFS: webFS, + runSources: make(map[string]string), + sweepSources: make(map[string]sweepSourceEntry), + } + + if remoteURL != "" { + s.remoteClient = client.NewWithAPIKey(remoteURL, remoteAPIKey) } s.setupRouter() @@ -75,7 +95,7 @@ func (s *Server) setupRouter() { // Config endpoint - tells frontend we're in local mode r.Get("/config", s.getConfig) - // Projects (local mode concept) + // Projects (local mode concept, extended to include remote) r.Get("/projects", s.listProjects) r.Get("/projects/{projectSlug}/runs", s.listRuns) @@ -84,6 +104,12 @@ func (s *Server) setupRouter() { r.Get("/teams/{teamSlug}/apps", s.listApps) r.Get("/teams/{teamSlug}/apps/{appSlug}/runs", s.listAppRuns) + // Sweeps + r.Get("/projects/{projectSlug}/sweeps", s.listSweeps) + r.Get("/sweeps/{sweepID}", s.getSweep) + r.Get("/sweeps/{sweepID}/runs", s.getSweepRuns) + r.Post("/sweeps/{sweepID}/stop", s.stopSweep) + // Active run (for UI auto-navigation) r.Get("/active-run", s.getActiveRun) r.Post("/active-run", s.setActiveRun) @@ -137,6 +163,46 @@ func (s *Server) Shutdown(ctx context.Context) error { return nil } +// isRemoteRun returns true if the run with the given ID is known to be remote. +func (s *Server) isRemoteRun(runID string) bool { + if s.remoteClient == nil { + return false + } + s.mu.RLock() + src, ok := s.runSources[runID] + s.mu.RUnlock() + return ok && src == "remote" +} + +// proxyRemote fetches a path from the remote API and writes it directly to w. +func (s *Server) proxyRemote(w http.ResponseWriter, path string) { + data, err := s.remoteClient.RawGet(path) + if err != nil { + writeError(w, http.StatusBadGateway, "Failed to fetch from remote") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) +} + +// cacheRunSource records the source for a set of run IDs extracted from a JSON array. +func (s *Server) cacheRemoteRunIDs(data []byte) { + var runs []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(data, &runs); err != nil { + return + } + s.mu.Lock() + for _, r := range runs { + if r.ID != "" { + s.runSources[r.ID] = "remote" + } + } + s.mu.Unlock() +} + // Handler functions func (s *Server) healthCheck(w http.ResponseWriter, r *http.Request) { @@ -150,9 +216,10 @@ func (s *Server) healthCheck(w http.ResponseWriter, r *http.Request) { func (s *Server) getConfig(w http.ResponseWriter, r *http.Request) { config := map[string]any{ - "mode": "local", - "version": "0.1.0", - "logdir": s.storage.LogDir(), + "mode": "local", + "version": "0.1.0", + "logdir": s.storage.LogDir(), + "hasRemote": s.remoteClient != nil, "features": map[string]bool{ "auth": false, "teams": false, @@ -184,12 +251,42 @@ func (s *Server) setActiveRun(w http.ResponseWriter, r *http.Request) { } func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { - projects, err := s.storage.ListProjects(r.Context()) + localProjects, err := s.storage.ListProjects(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to list projects") return } + // Convert to a mutable map slice so we can add source field + localJSON, _ := json.Marshal(localProjects) + var projects []map[string]any + json.Unmarshal(localJSON, &projects) + for i := range projects { + projects[i]["source"] = "local" + } + + // Append remote projects if configured + if s.remoteClient != nil { + teams, err := s.remoteClient.GetTeams() + if err == nil { + for _, t := range teams { + apps, appErr := s.remoteClient.GetApps(t.Slug) + if appErr != nil { + continue + } + for _, a := range apps { + projects = append(projects, map[string]any{ + "slug": a.Slug, + "name": a.Name, + "run_count": a.RunCount, + "source": "remote", + "team_slug": t.Slug, + }) + } + } + } + } + writeJSON(w, http.StatusOK, map[string]any{ "projects": projects, }) @@ -252,6 +349,23 @@ func (s *Server) listAppRuns(w http.ResponseWriter, r *http.Request) { func (s *Server) listRuns(w http.ResponseWriter, r *http.Request) { projectSlug := chi.URLParam(r, "projectSlug") + source := r.URL.Query().Get("source") + team := r.URL.Query().Get("team") + + // Route to remote if requested + if source == "remote" && s.remoteClient != nil && team != "" { + path := fmt.Sprintf("/api/v1/teams/%s/apps/%s/runs", team, projectSlug) + data, err := s.remoteClient.RawGet(path) + if err != nil { + writeError(w, http.StatusBadGateway, "Failed to fetch remote runs") + return + } + s.cacheRemoteRunIDs(data) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) + return + } opts := domain.RunListOptions{ Limit: 100, @@ -259,8 +373,8 @@ func (s *Server) listRuns(w http.ResponseWriter, r *http.Request) { OrderDir: r.URL.Query().Get("order_dir"), } - if status := r.URL.Query().Get("status"); status != "" { - s := domain.RunStatus(status) + if st := r.URL.Query().Get("status"); st != "" { + s := domain.RunStatus(st) opts.Status = &s } @@ -270,14 +384,40 @@ func (s *Server) listRuns(w http.ResponseWriter, r *http.Request) { return } + // Cache local run IDs + s.mu.Lock() + for _, run := range runs { + s.runSources[run.ID.String()] = "local" + } + s.mu.Unlock() + writeJSON(w, http.StatusOK, runs) } func (s *Server) getRun(w http.ResponseWriter, r *http.Request) { runID := chi.URLParam(r, "runID") + if s.isRemoteRun(runID) { + s.proxyRemote(w, fmt.Sprintf("/api/v1/runs/%s", runID)) + return + } + run, err := s.storage.GetRun(r.Context(), runID) if err != nil { + // Try remote as fallback if not in cache yet + if s.remoteClient != nil { + path := fmt.Sprintf("/api/v1/runs/%s", runID) + data, remoteErr := s.remoteClient.RawGet(path) + if remoteErr == nil { + s.mu.Lock() + s.runSources[runID] = "remote" + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) + return + } + } writeError(w, http.StatusNotFound, "Run not found") return } @@ -288,6 +428,11 @@ func (s *Server) getRun(w http.ResponseWriter, r *http.Request) { func (s *Server) getMetricNames(w http.ResponseWriter, r *http.Request) { runID := chi.URLParam(r, "runID") + if s.isRemoteRun(runID) { + s.proxyRemote(w, fmt.Sprintf("/api/v1/runs/%s/metrics", runID)) + return + } + names, err := s.storage.GetMetricNames(r.Context(), runID) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to get metric names") @@ -302,6 +447,11 @@ func (s *Server) getMetricNames(w http.ResponseWriter, r *http.Request) { func (s *Server) getLatestMetrics(w http.ResponseWriter, r *http.Request) { runID := chi.URLParam(r, "runID") + if s.isRemoteRun(runID) { + s.proxyRemote(w, fmt.Sprintf("/api/v1/runs/%s/metrics/latest", runID)) + return + } + latest, err := s.storage.GetLatestMetrics(r.Context(), runID) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to get latest metrics") @@ -314,6 +464,11 @@ func (s *Server) getLatestMetrics(w http.ResponseWriter, r *http.Request) { func (s *Server) getMetricsSummary(w http.ResponseWriter, r *http.Request) { runID := chi.URLParam(r, "runID") + if s.isRemoteRun(runID) { + s.proxyRemote(w, fmt.Sprintf("/api/v1/runs/%s/metrics/summary", runID)) + return + } + summary, err := s.storage.GetMetricsSummary(r.Context(), runID) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to get metrics summary") @@ -331,6 +486,16 @@ func (s *Server) getMetricSeries(w http.ResponseWriter, r *http.Request) { // This allows metric names like "train/loss" to be passed as "train%2Floss" metricName, _ := url.PathUnescape(metricNameRaw) + if s.isRemoteRun(runID) { + path := fmt.Sprintf("/api/v1/runs/%s/metrics/%s", runID, url.PathEscape(metricName)) + // Forward query params (max_points, min_step, max_step, etc.) + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + s.proxyRemote(w, path) + return + } + opts := storage.MetricQueryOptions{ MaxPoints: 1000, // Default max points Limit: 10000, @@ -390,6 +555,11 @@ func (s *Server) getMetricSeries(w http.ResponseWriter, r *http.Request) { func (s *Server) getContinuations(w http.ResponseWriter, r *http.Request) { runID := chi.URLParam(r, "runID") + if s.isRemoteRun(runID) { + s.proxyRemote(w, fmt.Sprintf("/api/v1/runs/%s/continuations", runID)) + return + } + continuations, err := s.storage.GetContinuations(r.Context(), runID) if err != nil { if strings.Contains(err.Error(), "not found") { @@ -408,6 +578,192 @@ func (s *Server) getContinuations(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"continuations": continuations}) } +func (s *Server) listSweeps(w http.ResponseWriter, r *http.Request) { + projectSlug := chi.URLParam(r, "projectSlug") + source := r.URL.Query().Get("source") + team := r.URL.Query().Get("team") + + if source == "remote" && s.remoteClient != nil && team != "" { + path := fmt.Sprintf("/api/v1/teams/%s/apps/%s/sweeps", team, projectSlug) + data, err := s.remoteClient.RawGet(path) + if err != nil { + writeError(w, http.StatusBadGateway, "Failed to fetch remote sweeps") + return + } + s.cacheRemoteSweepIDs(data, team, projectSlug) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) + return + } + + sweeps, err := s.storage.ListSweeps(r.Context(), projectSlug) + if err != nil { + writeError(w, http.StatusInternalServerError, "Failed to list sweeps") + return + } + + writeJSON(w, http.StatusOK, sweeps) +} + +func (s *Server) getSweep(w http.ResponseWriter, r *http.Request) { + sweepID := chi.URLParam(r, "sweepID") + source := r.URL.Query().Get("source") + team := r.URL.Query().Get("team") + project := r.URL.Query().Get("project") + + // Check cache, then fall back to query params for remote sweeps + if s.isRemoteSweep(sweepID) { + entry := s.getRemoteSweepEntry(sweepID) + path := fmt.Sprintf("/api/v1/teams/%s/apps/%s/sweeps/%s", entry.teamSlug, entry.appSlug, sweepID) + s.proxyRemote(w, path) + return + } + if source == "remote" && s.remoteClient != nil && team != "" && project != "" { + s.mu.Lock() + s.sweepSources[sweepID] = sweepSourceEntry{source: "remote", teamSlug: team, appSlug: project} + s.mu.Unlock() + path := fmt.Sprintf("/api/v1/teams/%s/apps/%s/sweeps/%s", team, project, sweepID) + s.proxyRemote(w, path) + return + } + + sweep, _, err := s.storage.GetSweepByID(r.Context(), sweepID) + if err != nil { + writeError(w, http.StatusNotFound, "Sweep not found") + return + } + + writeJSON(w, http.StatusOK, sweep) +} + +func (s *Server) getSweepRuns(w http.ResponseWriter, r *http.Request) { + sweepID := chi.URLParam(r, "sweepID") + source := r.URL.Query().Get("source") + team := r.URL.Query().Get("team") + project := r.URL.Query().Get("project") + + if s.isRemoteSweep(sweepID) { + path := fmt.Sprintf("/api/v1/sweeps/%s/runs", sweepID) + s.proxyRemote(w, path) + return + } + if source == "remote" && s.remoteClient != nil && team != "" && project != "" { + path := fmt.Sprintf("/api/v1/sweeps/%s/runs", sweepID) + s.proxyRemote(w, path) + return + } + + _, proj, err := s.storage.GetSweepByID(r.Context(), sweepID) + if err != nil { + writeError(w, http.StatusNotFound, "Sweep not found") + return + } + + runs, err := s.storage.GetSweepRuns(r.Context(), proj, sweepID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Failed to get sweep runs") + return + } + + if runs == nil { + runs = []*domain.Run{} + } + + // Cache local run IDs + s.mu.Lock() + for _, run := range runs { + s.runSources[run.ID.String()] = "local" + } + s.mu.Unlock() + + writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) +} + +func (s *Server) stopSweep(w http.ResponseWriter, r *http.Request) { + sweepID := chi.URLParam(r, "sweepID") + source := r.URL.Query().Get("source") + team := r.URL.Query().Get("team") + project := r.URL.Query().Get("project") + + if s.isRemoteSweep(sweepID) { + entry := s.getRemoteSweepEntry(sweepID) + path := fmt.Sprintf("/api/v1/teams/%s/apps/%s/sweeps/%s/stop", entry.teamSlug, entry.appSlug, sweepID) + data, err := s.remoteClient.RawPost(path, nil) + if err != nil { + writeError(w, http.StatusBadGateway, "Failed to stop remote sweep") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) + return + } + if source == "remote" && s.remoteClient != nil && team != "" && project != "" { + path := fmt.Sprintf("/api/v1/teams/%s/apps/%s/sweeps/%s/stop", team, project, sweepID) + data, err := s.remoteClient.RawPost(path, nil) + if err != nil { + writeError(w, http.StatusBadGateway, "Failed to stop remote sweep") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(data) + return + } + + sweep, proj, err := s.storage.GetSweepByID(r.Context(), sweepID) + if err != nil { + writeError(w, http.StatusNotFound, "Sweep not found") + return + } + + if err := s.storage.StopSweep(r.Context(), proj, sweepID); err != nil { + writeError(w, http.StatusInternalServerError, "Failed to stop sweep") + return + } + + sweep.Status = "stopped" + writeJSON(w, http.StatusOK, sweep) +} + +func (s *Server) isRemoteSweep(sweepID string) bool { + if s.remoteClient == nil { + return false + } + s.mu.RLock() + entry, ok := s.sweepSources[sweepID] + s.mu.RUnlock() + return ok && entry.source == "remote" +} + +func (s *Server) getRemoteSweepEntry(sweepID string) sweepSourceEntry { + s.mu.RLock() + entry := s.sweepSources[sweepID] + s.mu.RUnlock() + return entry +} + +func (s *Server) cacheRemoteSweepIDs(data []byte, teamSlug, appSlug string) { + var sweeps []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(data, &sweeps); err != nil { + return + } + s.mu.Lock() + for _, sw := range sweeps { + if sw.ID != "" { + s.sweepSources[sw.ID] = sweepSourceEntry{ + source: "remote", + teamSlug: teamSlug, + appSlug: appSlug, + } + } + } + s.mu.Unlock() +} + func (s *Server) serveWebUI(w http.ResponseWriter, r *http.Request) { // SPA serving: try the requested file, fall back to index.html for client-side routing path := r.URL.Path diff --git a/internal/storage/file/storage.go b/internal/storage/file/storage.go index cc4a548..daea993 100644 --- a/internal/storage/file/storage.go +++ b/internal/storage/file/storage.go @@ -726,6 +726,180 @@ func (s *Storage) GetContinuations(ctx context.Context, runID string) ([]*domain return continuations, nil } +// ListSweeps returns all sweeps in a project. +func (s *Storage) ListSweeps(ctx context.Context, project string) ([]domain.Sweep, error) { + sweepsDir := filepath.Join(s.logdir, project, ".sweeps") + entries, err := os.ReadDir(sweepsDir) + if err != nil { + if os.IsNotExist(err) { + return []domain.Sweep{}, nil + } + return nil, fmt.Errorf("failed to read sweeps directory: %w", err) + } + + var sweeps []domain.Sweep + for _, entry := range entries { + if !entry.IsDir() { + continue + } + sweep, err := s.loadSweep(project, entry.Name()) + if err != nil { + continue + } + sweeps = append(sweeps, *sweep) + } + + sort.Slice(sweeps, func(i, j int) bool { + return sweeps[i].CreatedAt.After(sweeps[j].CreatedAt) + }) + + return sweeps, nil +} + +// GetSweep returns a sweep by ID within a project. +func (s *Storage) GetSweep(ctx context.Context, project, sweepID string) (*domain.Sweep, error) { + return s.loadSweep(project, sweepID) +} + +// GetSweepByID searches all projects for a sweep by ID. +func (s *Storage) GetSweepByID(ctx context.Context, sweepID string) (*domain.Sweep, string, error) { + projects, err := os.ReadDir(s.logdir) + if err != nil { + return nil, "", fmt.Errorf("failed to read logdir: %w", err) + } + + for _, project := range projects { + if !project.IsDir() { + continue + } + sweep, err := s.loadSweep(project.Name(), sweepID) + if err == nil { + return sweep, project.Name(), nil + } + } + + return nil, "", fmt.Errorf("sweep not found: %s", sweepID) +} + +// GetSweepRuns returns runs belonging to a sweep. +func (s *Storage) GetSweepRuns(ctx context.Context, project, sweepID string) ([]*domain.Run, error) { + sweepFile := filepath.Join(s.logdir, project, ".sweeps", sweepID, "sweep.json") + data, err := os.ReadFile(sweepFile) + if err != nil { + return nil, fmt.Errorf("sweep not found: %w", err) + } + + var raw struct { + Runs []struct { + RunID string `json:"run_id"` + } `json:"runs"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse sweep: %w", err) + } + + var runs []*domain.Run + for _, r := range raw.Runs { + if r.RunID == "" { + continue + } + run, err := s.GetRun(ctx, r.RunID) + if err != nil { + continue + } + runs = append(runs, run) + } + + return runs, nil +} + +// StopSweep updates a sweep's status to stopped. +func (s *Storage) StopSweep(ctx context.Context, project, sweepID string) error { + sweepFile := filepath.Join(s.logdir, project, ".sweeps", sweepID, "sweep.json") + data, err := os.ReadFile(sweepFile) + if err != nil { + return fmt.Errorf("sweep not found: %w", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("failed to parse sweep: %w", err) + } + + raw["status"] = "stopped" + + updated, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + + return os.WriteFile(sweepFile, updated, 0644) +} + +func (s *Storage) loadSweep(project, sweepID string) (*domain.Sweep, error) { + sweepFile := filepath.Join(s.logdir, project, ".sweeps", sweepID, "sweep.json") + data, err := os.ReadFile(sweepFile) + if err != nil { + return nil, err + } + + var raw struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Method string `json:"method"` + MetricName string `json:"metric_name"` + MetricGoal string `json:"metric_goal"` + SearchSpace domain.SearchSpace `json:"search_space"` + Config map[string]any `json:"config"` + MaxRuns *int `json:"max_runs"` + EarlyStopping *domain.EarlyStoppingConfig `json:"early_stopping"` + BestRunID *string `json:"best_run_id"` + BestValue *float64 `json:"best_value"` + RunCount int `json:"run_count"` + GridIndex int `json:"grid_index"` + StartedAt string `json:"started_at"` + CreatedAt string `json:"created_at"` + } + + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse sweep.json: %w", err) + } + + createdAt, _ := time.Parse(time.RFC3339, raw.CreatedAt) + startedAt := createdAt + if raw.StartedAt != "" { + if t, err := time.Parse(time.RFC3339, raw.StartedAt); err == nil { + startedAt = t + } + } + + // Use directory name as fallback ID + id := raw.ID + if id == "" { + id = sweepID + } + + return &domain.Sweep{ + ID: id, + Name: raw.Name, + Status: domain.SweepStatus(raw.Status), + Method: raw.Method, + MetricName: raw.MetricName, + MetricGoal: raw.MetricGoal, + SearchSpace: raw.SearchSpace, + Config: raw.Config, + MaxRuns: raw.MaxRuns, + EarlyStopping: raw.EarlyStopping, + BestRunID: raw.BestRunID, + BestValue: raw.BestValue, + RunCount: raw.RunCount, + GridIndex: raw.GridIndex, + StartedAt: startedAt, + CreatedAt: createdAt, + }, nil +} + // findRunDir finds the directory for a run by ID. func (s *Storage) findRunDir(runID string) (string, error) { projects, err := os.ReadDir(s.logdir) diff --git a/internal/storage/interface.go b/internal/storage/interface.go index 66b942f..7b0ee2b 100644 --- a/internal/storage/interface.go +++ b/internal/storage/interface.go @@ -33,6 +33,13 @@ type Storage interface { GetLatestMetrics(ctx context.Context, runID string) (map[string]float64, error) GetMetricsSummary(ctx context.Context, runID string) (*MetricsSummary, error) + // Sweeps + ListSweeps(ctx context.Context, project string) ([]domain.Sweep, error) + GetSweep(ctx context.Context, project, sweepID string) (*domain.Sweep, error) + GetSweepByID(ctx context.Context, sweepID string) (*domain.Sweep, string, error) + GetSweepRuns(ctx context.Context, project, sweepID string) ([]*domain.Run, error) + StopSweep(ctx context.Context, project, sweepID string) error + // Health check Health(ctx context.Context) error diff --git a/mise.toml b/mise.toml index b1a06a4..6e144db 100644 --- a/mise.toml +++ b/mise.toml @@ -107,6 +107,23 @@ trap "kill $API_PID $WEB_PID 2>/dev/null" EXIT wait """ +[tasks.dev-cloud] +description = "Run API + web UI dev server with local and cloud runs" +depends = ["build-go"] +run = """ +# Start API server in background with remote cloud config +./bin/pnf serve --logdir {{option(name="logdir", default="./logs")}} --port 6767 --open=false --url {{option(name="url", default="https://p.ninetyfive.gg")}} --api-key {{option(name="api-key", default="ss67_GuRMyZWyzbRVwtcPc91XcTCyw-n_GKVFCYB-XJa_3cQ=")}} & +API_PID=$! + +# Start web dev server (connects to API on :6767) +cd web && npm install && npm run dev & +WEB_PID=$! + +# Wait for either to exit +trap "kill $API_PID $WEB_PID 2>/dev/null" EXIT +wait +""" + [tasks.test] description = "Run Go tests" run = "go test ./..." diff --git a/pkg/client/client.go b/pkg/client/client.go index 9e62bca..b7d4be0 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -267,6 +267,18 @@ func (c *Client) GetMetricNames(runID uuid.UUID) ([]string, error) { return resp.Metrics, nil } +// RawGet makes a GET request and returns the raw response bytes. +// Used by the server to proxy remote API responses. +func (c *Client) RawGet(path string) ([]byte, error) { + return c.request("GET", path, nil) +} + +// RawPost makes a POST request and returns the raw response bytes. +// Used by the server to proxy remote API responses. +func (c *Client) RawPost(path string, body any) ([]byte, error) { + return c.request("POST", path, body) +} + // GetContinuations returns all continuations for a run func (c *Client) GetContinuations(runID uuid.UUID) ([]Continuation, error) { data, err := c.request("GET", fmt.Sprintf("/api/v1/runs/%s/continuations", runID), nil) diff --git a/web/src/api/projects.ts b/web/src/api/projects.ts index e15d070..95b79fb 100644 --- a/web/src/api/projects.ts +++ b/web/src/api/projects.ts @@ -6,6 +6,8 @@ export interface Project { name: string; run_count: number; last_updated: string; + source: "local" | "remote"; + team_slug?: string; } export async function getProjects(): Promise { @@ -13,8 +15,18 @@ export async function getProjects(): Promise { return response.data.projects || []; } -export async function getProjectRuns(projectSlug: string): Promise { - const response = await apiClient.get(`/projects/${projectSlug}/runs`); +export async function getProjectRuns( + projectSlug: string, + opts?: { source?: string; team?: string }, +): Promise { + const params: Record = {}; + if (opts?.source === "remote" && opts.team) { + params.source = "remote"; + params.team = opts.team; + } + const response = await apiClient.get(`/projects/${projectSlug}/runs`, { + params, + }); return response.data; } diff --git a/web/src/api/sweeps.ts b/web/src/api/sweeps.ts new file mode 100644 index 0000000..11c5107 --- /dev/null +++ b/web/src/api/sweeps.ts @@ -0,0 +1,78 @@ +import { apiClient } from "./client"; +import type { Sweep, SweepFilters, Run } from "./types"; + +export async function getProjectSweeps( + projectSlug: string, + opts?: { source?: string; team?: string }, + filters?: SweepFilters, +): Promise { + const params: Record = {}; + if (opts?.source === "remote" && opts.team) { + params.source = "remote"; + params.team = opts.team; + } + if (filters?.limit) params.limit = filters.limit; + if (filters?.order_by) params.order_by = filters.order_by; + if (filters?.order_dir) params.order_dir = filters.order_dir; + + const response = await apiClient.get( + `/projects/${projectSlug}/sweeps`, + { params }, + ); + return response.data; +} + +export interface SweepContext { + source?: string; + team?: string; + project?: string; +} + +export async function getSweep( + sweepId: string, + ctx?: SweepContext, +): Promise { + const params: Record = {}; + if (ctx?.source === "remote" && ctx.team && ctx.project) { + params.source = "remote"; + params.team = ctx.team; + params.project = ctx.project; + } + const response = await apiClient.get(`/sweeps/${sweepId}`, { params }); + return response.data; +} + +export async function getSweepRuns( + sweepId: string, + ctx?: SweepContext, +): Promise { + const params: Record = {}; + if (ctx?.source === "remote" && ctx.team && ctx.project) { + params.source = "remote"; + params.team = ctx.team; + params.project = ctx.project; + } + const response = await apiClient.get<{ runs: Run[] }>( + `/sweeps/${sweepId}/runs`, + { params }, + ); + return response.data.runs; +} + +export async function stopSweep( + sweepId: string, + ctx?: SweepContext, +): Promise { + const params: Record = {}; + if (ctx?.source === "remote" && ctx.team && ctx.project) { + params.source = "remote"; + params.team = ctx.team; + params.project = ctx.project; + } + const response = await apiClient.post( + `/sweeps/${sweepId}/stop`, + undefined, + { params }, + ); + return response.data; +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 754ecbe..f5c25d3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -174,6 +174,57 @@ export interface MetricsSummaryResponse { metrics: MetricSummary[]; } +// Sweep types +export type SweepStatus = "running" | "completed" | "failed" | "stopped"; +export type SearchMethod = "random" | "grid"; +export type MetricGoal = "minimize" | "maximize"; + +export interface ParameterSpec { + name: string; + type: "uniform" | "log_uniform" | "int" | "categorical"; + min?: number; + max?: number; + values?: unknown[]; +} + +export interface SearchSpace { + parameters: ParameterSpec[]; +} + +export interface EarlyStoppingConfig { + method: string; + min_steps: number; + warmup: number; +} + +export interface Sweep { + id: string; + name: string; + status: SweepStatus; + method: SearchMethod; + metric_name: string; + metric_goal: MetricGoal; + search_space: SearchSpace; + config?: Record; + max_runs?: number; + early_stopping?: EarlyStoppingConfig; + best_run_id?: string; + best_value?: number; + run_count: number; + grid_index: number; + started_at: string; + ended_at?: string; + created_at: string; +} + +export interface SweepFilters { + status?: SweepStatus; + limit?: number; + offset?: number; + order_by?: "started_at" | "name" | "status"; + order_dir?: "asc" | "desc"; +} + // API Key types export type APIKeyScope = "read" | "write" | "admin"; diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 33c1dc2..4793536 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -9,13 +9,20 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SweepsRouteImport } from './routes/sweeps' import { Route as RunsRouteImport } from './routes/runs' import { Route as ProjectsRouteImport } from './routes/projects' import { Route as IndexRouteImport } from './routes/index' import { Route as ProjectsIndexRouteImport } from './routes/projects/index' +import { Route as SweepsSweepIdRouteImport } from './routes/sweeps/$sweepId' import { Route as RunsRunIdRouteImport } from './routes/runs/$runId' import { Route as ProjectsProjectSlugRouteImport } from './routes/projects/$projectSlug' +const SweepsRoute = SweepsRouteImport.update({ + id: '/sweeps', + path: '/sweeps', + getParentRoute: () => rootRouteImport, +} as any) const RunsRoute = RunsRouteImport.update({ id: '/runs', path: '/runs', @@ -36,6 +43,11 @@ const ProjectsIndexRoute = ProjectsIndexRouteImport.update({ path: '/', getParentRoute: () => ProjectsRoute, } as any) +const SweepsSweepIdRoute = SweepsSweepIdRouteImport.update({ + id: '/$sweepId', + path: '/$sweepId', + getParentRoute: () => SweepsRoute, +} as any) const RunsRunIdRoute = RunsRunIdRouteImport.update({ id: '/$runId', path: '/$runId', @@ -51,15 +63,19 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/projects': typeof ProjectsRouteWithChildren '/runs': typeof RunsRouteWithChildren + '/sweeps': typeof SweepsRouteWithChildren '/projects/$projectSlug': typeof ProjectsProjectSlugRoute '/runs/$runId': typeof RunsRunIdRoute + '/sweeps/$sweepId': typeof SweepsSweepIdRoute '/projects/': typeof ProjectsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/runs': typeof RunsRouteWithChildren + '/sweeps': typeof SweepsRouteWithChildren '/projects/$projectSlug': typeof ProjectsProjectSlugRoute '/runs/$runId': typeof RunsRunIdRoute + '/sweeps/$sweepId': typeof SweepsSweepIdRoute '/projects': typeof ProjectsIndexRoute } export interface FileRoutesById { @@ -67,8 +83,10 @@ export interface FileRoutesById { '/': typeof IndexRoute '/projects': typeof ProjectsRouteWithChildren '/runs': typeof RunsRouteWithChildren + '/sweeps': typeof SweepsRouteWithChildren '/projects/$projectSlug': typeof ProjectsProjectSlugRoute '/runs/$runId': typeof RunsRunIdRoute + '/sweeps/$sweepId': typeof SweepsSweepIdRoute '/projects/': typeof ProjectsIndexRoute } export interface FileRouteTypes { @@ -77,18 +95,29 @@ export interface FileRouteTypes { | '/' | '/projects' | '/runs' + | '/sweeps' | '/projects/$projectSlug' | '/runs/$runId' + | '/sweeps/$sweepId' | '/projects/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/runs' | '/projects/$projectSlug' | '/runs/$runId' | '/projects' + to: + | '/' + | '/runs' + | '/sweeps' + | '/projects/$projectSlug' + | '/runs/$runId' + | '/sweeps/$sweepId' + | '/projects' id: | '__root__' | '/' | '/projects' | '/runs' + | '/sweeps' | '/projects/$projectSlug' | '/runs/$runId' + | '/sweeps/$sweepId' | '/projects/' fileRoutesById: FileRoutesById } @@ -96,10 +125,18 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ProjectsRoute: typeof ProjectsRouteWithChildren RunsRoute: typeof RunsRouteWithChildren + SweepsRoute: typeof SweepsRouteWithChildren } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/sweeps': { + id: '/sweeps' + path: '/sweeps' + fullPath: '/sweeps' + preLoaderRoute: typeof SweepsRouteImport + parentRoute: typeof rootRouteImport + } '/runs': { id: '/runs' path: '/runs' @@ -128,6 +165,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsIndexRouteImport parentRoute: typeof ProjectsRoute } + '/sweeps/$sweepId': { + id: '/sweeps/$sweepId' + path: '/$sweepId' + fullPath: '/sweeps/$sweepId' + preLoaderRoute: typeof SweepsSweepIdRouteImport + parentRoute: typeof SweepsRoute + } '/runs/$runId': { id: '/runs/$runId' path: '/$runId' @@ -169,10 +213,22 @@ const RunsRouteChildren: RunsRouteChildren = { const RunsRouteWithChildren = RunsRoute._addFileChildren(RunsRouteChildren) +interface SweepsRouteChildren { + SweepsSweepIdRoute: typeof SweepsSweepIdRoute +} + +const SweepsRouteChildren: SweepsRouteChildren = { + SweepsSweepIdRoute: SweepsSweepIdRoute, +} + +const SweepsRouteWithChildren = + SweepsRoute._addFileChildren(SweepsRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ProjectsRoute: ProjectsRouteWithChildren, RunsRoute: RunsRouteWithChildren, + SweepsRoute: SweepsRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/web/src/routes/projects/$projectSlug.tsx b/web/src/routes/projects/$projectSlug.tsx index 4e22636..90e56e5 100644 --- a/web/src/routes/projects/$projectSlug.tsx +++ b/web/src/routes/projects/$projectSlug.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { useState, useMemo } from "react"; import { getProjectRuns } from "@/api/projects"; +import { getProjectSweeps } from "@/api/sweeps"; import { getMetricNames } from "@/api/metrics"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -16,40 +17,83 @@ import { } from "@/components/ui/table"; import { RunStatusBadge } from "@/components/runs/run-status-badge"; import { formatRelativeTime, formatDuration } from "@/lib/utils"; -import { ChevronLeft, Activity, X } from "lucide-react"; +import { + ChevronLeft, + Activity, + X, + Cloud, + Shuffle, + Grid3X3, + Trophy, + ArrowRight, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ComparisonChart } from "@/components/metrics/comparison-chart"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import type { XAxisMode, YAxisScale } from "@/components/metrics/metric-chart"; +import type { SweepStatus } from "@/api/types"; export const Route = createFileRoute("/projects/$projectSlug")({ component: ProjectRunsPage, }); +function SweepStatusBadge({ status }: { status: SweepStatus }) { + const variants: Record< + SweepStatus, + "default" | "success" | "destructive" | "secondary" + > = { + running: "default", + completed: "success", + failed: "destructive", + stopped: "secondary", + }; + return {status}; +} + function ProjectRunsPage() { - // Get projectSlug from URL const projectSlug = window.location.pathname.split("/projects/")[1]?.split("/")[0] || ""; + const searchParams = new URLSearchParams(window.location.search); + const source = searchParams.get("source") || "local"; + const team = searchParams.get("team") || ""; + const isRemote = source === "remote"; + + const sweepHref = (sweepId: string) => { + if (isRemote && team) { + return `/sweeps/${sweepId}?source=remote&team=${encodeURIComponent(team)}&project=${encodeURIComponent(projectSlug)}`; + } + return `/sweeps/${sweepId}`; + }; + const [activeTab, setActiveTab] = useState("runs"); const [selectedRunIds, setSelectedRunIds] = useState>(new Set()); const [xAxisMode, setXAxisMode] = useState("step"); const [yAxisScale, setYAxisScale] = useState("linear"); const [selectedMetric, setSelectedMetric] = useState(null); - const { data: runs, isLoading } = useQuery({ - queryKey: ["project-runs", projectSlug], - queryFn: () => getProjectRuns(projectSlug), - refetchInterval: 3000, // Poll for updates + const { data: runs, isLoading: runsLoading } = useQuery({ + queryKey: ["project-runs", projectSlug, source, team], + queryFn: () => getProjectRuns(projectSlug, { source, team }), + refetchInterval: 3000, + }); + + const { data: sweeps, isLoading: sweepsLoading } = useQuery({ + queryKey: ["project-sweeps", projectSlug, source, team], + queryFn: () => + getProjectSweeps( + projectSlug, + { source, team }, + { limit: 50, order_by: "started_at", order_dir: "desc" }, + ), + refetchInterval: 5000, }); - // Get selected runs for comparison const selectedRuns = useMemo(() => { if (!runs) return []; return runs.filter((r) => selectedRunIds.has(r.id)); }, [runs, selectedRunIds]); - // Get metric names from the first selected run const firstSelectedRunId = selectedRuns[0]?.id; const { data: metricNames } = useQuery({ queryKey: ["metrics", "names", firstSelectedRunId], @@ -87,7 +131,9 @@ function ProjectRunsPage() { const allSelected = runs && runs.length > 0 && selectedRunIds.size === runs.length; - if (isLoading) { + const isLoading = activeTab === "runs" ? runsLoading : sweepsLoading; + + if (isLoading && activeTab === "runs" && !runs) { return (
@@ -114,246 +160,398 @@ function ProjectRunsPage() {

{projectSlug} + {isRemote && ( + + + Cloud + + )}

{runs?.length || 0} run{(runs?.length || 0) !== 1 ? "s" : ""} + {(sweeps?.length || 0) > 0 && ( + + {" "} + · {sweeps?.length} sweep + {(sweeps?.length || 0) !== 1 ? "s" : ""} + + )}

- {/* Main content - side by side when comparing */} - {!runs || runs.length === 0 ? ( - - - No runs in this project yet - - - ) : ( -
- {/* Runs table - shrinks when comparing */} -
- {/* Comparison controls */} - {isComparing && ( -
- - {selectedRunIds.size} selected - - + {/* Tabs */} + + + + Runs {runs !== undefined && `(${runs.length})`} + + + Sweeps {sweeps !== undefined && `(${sweeps.length})`} + + + + {/* Runs Tab */} + + {!runs || runs.length === 0 ? ( + + + No runs in this project yet + + + ) : ( +
+ {/* Runs table */} +
+ {isComparing && ( +
+ + {selectedRunIds.size} selected + + +
+ )} + + + + + + +
+ +
+
+ Name + Status + {!isComparing && ( + <> + Tags + Started + Duration + + )} +
+
+ + {runs.map((run) => ( + + +
+ + toggleRunSelection(run.id) + } + /> +
+
+ + + {run.name} + + {!isComparing && run.git_info?.branch && ( + + {run.git_info.branch} + + )} + + + + + {!isComparing && ( + <> + +
+ {run.tags?.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {run.tags && run.tags.length > 3 && ( + + +{run.tags.length - 3} + + )} +
+
+ + {formatRelativeTime(run.started_at)} + + + {run.duration_seconds !== undefined + ? formatDuration(run.duration_seconds) + : run.status === "running" + ? "Running..." + : "-"} + + + )} +
+ ))} +
+
+
- )} + {/* Comparison panel */} + {isComparing && ( +
+ + +
+ + Run Comparison + +
+
+ + X: + + + +
+
+ + Y: + + + +
+
+
+
+ + {selectedRuns.length < 2 ? ( +
+ Select at least 2 runs to compare +
+ ) : metricNames && metricNames.length > 0 ? ( + + + {metricNames.map((name) => ( + + {name} + + ))} + + {metricNames.map((name) => ( + + ({ + id: r.id, + name: r.name, + }))} + metricName={name} + height={400} + xAxisMode={xAxisMode} + yAxisScale={yAxisScale} + /> +

+ Click on a run in the legend to highlight it +

+
+ ))} +
+ ) : ( +
+ No metrics available for comparison +
+ )} +
+
+
+ )} +
+ )} +
+ + {/* Sweeps Tab */} + + {sweepsLoading ? ( + + ) : !sweeps || sweeps.length === 0 ? ( + + +

No sweeps yet

+

+ Start a hyperparameter sweep with the SDK: +

+
+                  {`import p95
+
+sweep_id = p95.sweep(
+    project="${projectSlug}",
+    config=p95.SweepConfig(
+        method="random",
+        metric="val_loss",
+        goal="minimize",
+        parameters=[...],
+        max_runs=20,
+    ),
+)
+
+p95.agent(sweep_id, train_fn)`}
+                
+
+
+ ) : ( - -
- -
-
Name - Status - {!isComparing && ( - <> - Tags - Started - Duration - - )} + Method + Status + Metric + Runs + Best Value + Started +
- {runs.map((run) => ( - - -
- toggleRunSelection(run.id)} - /> -
-
- + {sweeps.map((sweep) => ( + + - {run.name} + {sweep.name} - {!isComparing && run.git_info?.branch && ( - - {run.git_info.branch} + + +
+ {sweep.method === "random" ? ( + + ) : ( + + )} + {sweep.method} +
+
+ + + + + + {sweep.metric_name} + + ({sweep.metric_goal}) + + + + + + {sweep.run_count} + {sweep.max_runs && ( + + /{sweep.max_runs} + + )} + + + + {sweep.best_value !== undefined && + sweep.best_value !== null ? ( +
+ + {sweep.best_value.toFixed(4)} +
+ ) : ( + + - )}
- - + + {formatRelativeTime(sweep.started_at)} + + + + + - {!isComparing && ( - <> - -
- {run.tags?.slice(0, 3).map((tag) => ( - - {tag} - - ))} - {run.tags && run.tags.length > 3 && ( - - +{run.tags.length - 3} - - )} -
-
- - {formatRelativeTime(run.started_at)} - - - {run.duration_seconds !== undefined - ? formatDuration(run.duration_seconds) - : run.status === "running" - ? "Running..." - : "-"} - - - )}
))}
-
- - {/* Comparison panel - appears on right when runs selected */} - {isComparing && ( -
- - -
- Run Comparison -
- {/* X-axis toggle */} -
- - X: - - - -
- {/* Y-axis toggle */} -
- - Y: - - - -
-
-
-
- - {selectedRuns.length < 2 ? ( -
- Select at least 2 runs to compare -
- ) : metricNames && metricNames.length > 0 ? ( - - - {metricNames.map((name) => ( - - {name} - - ))} - - {metricNames.map((name) => ( - - ({ - id: r.id, - name: r.name, - }))} - metricName={name} - height={400} - xAxisMode={xAxisMode} - yAxisScale={yAxisScale} - /> -

- Click on a run in the legend to highlight it -

-
- ))} -
- ) : ( -
- No metrics available for comparison -
- )} -
-
-
)} -
- )} + +
); } diff --git a/web/src/routes/projects/index.tsx b/web/src/routes/projects/index.tsx index 66b9213..bf3302a 100644 --- a/web/src/routes/projects/index.tsx +++ b/web/src/routes/projects/index.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { getProjects } from "@/api/projects"; +import type { Project } from "@/api/projects"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Badge } from "@/components/ui/badge"; @@ -11,6 +12,13 @@ export const Route = createFileRoute("/projects/")({ component: ProjectsPage, }); +function projectHref(project: Project): string { + if (project.source === "remote" && project.team_slug) { + return `/projects/${project.slug}?source=remote&team=${encodeURIComponent(project.team_slug)}`; + } + return `/projects/${project.slug}`; +} + function ProjectsPage() { const { data: projects, isLoading } = useQuery({ queryKey: ["projects"], @@ -62,8 +70,8 @@ with Run(project="my-project") as run:
{projects.map((project) => ( @@ -75,13 +83,22 @@ with Run(project="my-project") as run:
- - {project.run_count} run - {project.run_count !== 1 ? "s" : ""} - - - {formatRelativeTime(project.last_updated)} - +
+ + {project.run_count} run + {project.run_count !== 1 ? "s" : ""} + + {project.source === "remote" && ( + + Cloud + + )} +
+ {project.last_updated && ( + + {formatRelativeTime(project.last_updated)} + + )}
diff --git a/web/src/routes/sweeps.tsx b/web/src/routes/sweeps.tsx new file mode 100644 index 0000000..7d826f2 --- /dev/null +++ b/web/src/routes/sweeps.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; + +export const Route = createFileRoute("/sweeps")({ + component: () => , +}); diff --git a/web/src/routes/sweeps/$sweepId.tsx b/web/src/routes/sweeps/$sweepId.tsx new file mode 100644 index 0000000..9dafbb4 --- /dev/null +++ b/web/src/routes/sweeps/$sweepId.tsx @@ -0,0 +1,731 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + getSweep, + getSweepRuns, + stopSweep, + type SweepContext, +} from "@/api/sweeps"; +import { getMetricNames } from "@/api/metrics"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatRelativeTime, formatDuration } from "@/lib/utils"; +import { RunStatusBadge } from "@/components/runs/run-status-badge"; +import { ComparisonChart } from "@/components/metrics/comparison-chart"; +import type { XAxisMode, YAxisScale } from "@/components/metrics/metric-chart"; +import { + ChevronLeft, + ChevronRight, + Shuffle, + Grid3X3, + Square, + Clock, + TrendingUp, + TrendingDown, + Trophy, +} from "lucide-react"; +import type { SweepStatus } from "@/api/types"; +import { + ScatterChart, + Scatter, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; + +export const Route = createFileRoute("/sweeps/$sweepId")({ + component: SweepDetailPage, +}); + +function SweepStatusBadge({ status }: { status: SweepStatus }) { + const variants: Record< + SweepStatus, + "default" | "success" | "destructive" | "secondary" + > = { + running: "default", + completed: "success", + failed: "destructive", + stopped: "secondary", + }; + return {status}; +} + +const RUNS_PER_PAGE = 10; + +function SweepDetailPage() { + const { sweepId } = Route.useParams(); + const queryClient = useQueryClient(); + + // Read source context from URL search params (set when linking from project page) + const searchParams = new URLSearchParams(window.location.search); + const sweepCtx: SweepContext = { + source: searchParams.get("source") || undefined, + team: searchParams.get("team") || undefined, + project: searchParams.get("project") || undefined, + }; + + const [runsPage, setRunsPage] = useState(0); + const [selectedParam, setSelectedParam] = useState(null); + const [xAxisMode, setXAxisMode] = useState("step"); + const [yAxisScale, setYAxisScale] = useState("linear"); + const [selectedMetric, setSelectedMetric] = useState(null); + + const { data: sweep, isLoading: sweepLoading } = useQuery({ + queryKey: ["sweep", sweepId, sweepCtx.source, sweepCtx.team], + queryFn: () => getSweep(sweepId, sweepCtx), + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === "running" ? 3000 : false; + }, + }); + + const { data: runs = [] } = useQuery({ + queryKey: ["sweep", sweepId, "runs", sweepCtx.source, sweepCtx.team], + queryFn: () => getSweepRuns(sweepId, sweepCtx), + refetchInterval: sweep?.status === "running" ? 3000 : false, + }); + + const firstRunId = runs[0]?.id; + const { data: metricNames = [] } = useQuery({ + queryKey: ["metrics", "names", firstRunId], + queryFn: () => getMetricNames(firstRunId!), + enabled: !!firstRunId, + refetchInterval: sweep?.status === "running" ? 5000 : false, + }); + + const stopMutation = useMutation({ + mutationFn: () => stopSweep(sweepId, sweepCtx), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sweep", sweepId] }); + }, + }); + + const parameterNames = useMemo(() => { + return sweep?.search_space.parameters.map((p) => p.name) || []; + }, [sweep?.search_space]); + + // Build scatter data: param value vs final metric value from run config + const parameterScatterData = useMemo(() => { + if (!selectedParam || !sweep) return []; + return runs + .filter((run) => run.config && run.config[selectedParam] !== undefined) + .map((run) => { + const paramValue = run.config![selectedParam]; + const metricValue = run.latest_metrics?.[sweep.metric_name]; + return { + name: run.name, + param: + typeof paramValue === "number" ? paramValue : String(paramValue), + metric: metricValue, + isBest: run.id === sweep.best_run_id, + }; + }) + .filter((d) => d.metric !== undefined); + }, [selectedParam, runs, sweep]); + + if (sweepLoading) { + return ( +
+ + +
+ ); + } + + if (!sweep) { + return ( +
+ Sweep not found +
+ ); + } + + const backHref = window.history.length > 1 ? undefined : "/projects"; + + return ( +
+ {/* Back button */} + { + e.preventDefault(); + window.history.back(); + } + } + > + + + + {/* Header */} +
+
+
+

{sweep.name}

+ +
+
+ + {sweep.method === "random" ? ( + + ) : ( + + )} + {sweep.method} search + + + {sweep.metric_goal === "minimize" ? ( + + ) : ( + + )} + {sweep.metric_goal} {sweep.metric_name} + + + {sweep.run_count} + {sweep.max_runs && `/${sweep.max_runs}`} runs + + Started {formatRelativeTime(sweep.started_at)} +
+
+ {sweep.status === "running" && ( + + )} +
+ + {/* Best Run Card */} + {sweep.best_run_id && + sweep.best_value !== undefined && + sweep.best_value !== null && ( + + + + + Best Run + + + +
+
+ + {runs.find((r) => r.id === sweep.best_run_id)?.name || + sweep.best_run_id} + +

+ {sweep.metric_name}: {sweep.best_value.toFixed(6)} +

+
+ {runs.find((r) => r.id === sweep.best_run_id)?.config && ( +
+ {Object.entries( + runs.find((r) => r.id === sweep.best_run_id)!.config!, + ) + .slice(0, 3) + .map(([key, value]) => ( +
+ {key}:{" "} + + {typeof value === "number" + ? (value as number).toPrecision(4) + : String(value)} + +
+ ))} +
+ )} +
+
+
+ )} + + {/* Summary Stats */} +
+ + + + Total Runs + + + +
+ {sweep.run_count} + {sweep.max_runs && ( + + /{sweep.max_runs} + + )} +
+
+
+ + + + Best {sweep.metric_name} + + + +
+ {sweep.best_value !== undefined && sweep.best_value !== null + ? sweep.best_value.toFixed(4) + : "-"} +
+
+
+ + + + Parameters + + + +
+ {sweep.search_space.parameters.length} +
+
+
+ + + + Duration + + + +
+ + {sweep.ended_at + ? formatDuration( + (new Date(sweep.ended_at).getTime() - + new Date(sweep.started_at).getTime()) / + 1000, + ) + : "Running..."} +
+
+
+
+ + {/* Tabs */} + + + Runs + Metrics + Parameters + Config + + + {/* Runs Tab */} + + + + + + Name + Status + Parameters + {sweep.metric_name} + Duration + + + + {runs + .slice( + runsPage * RUNS_PER_PAGE, + (runsPage + 1) * RUNS_PER_PAGE, + ) + .map((run) => { + const metricValue = run.latest_metrics?.[sweep.metric_name]; + const isBest = run.id === sweep.best_run_id; + + return ( + + + + {run.name} + + + + + + + {run.config && ( +
+ {Object.entries(run.config) + .slice(0, 4) + .map(([key, value]) => ( + + {key}= + {typeof value === "number" + ? (value as number).toPrecision(3) + : String(value)} + + ))} +
+ )} +
+ + {metricValue !== undefined + ? metricValue.toFixed(4) + : "-"} + + + {run.duration_seconds !== undefined + ? formatDuration(run.duration_seconds) + : run.status === "running" + ? "Running..." + : "-"} + +
+ ); + })} +
+
+ {runs.length > RUNS_PER_PAGE && ( +
+ + Showing {runsPage * RUNS_PER_PAGE + 1}- + {Math.min((runsPage + 1) * RUNS_PER_PAGE, runs.length)} of{" "} + {runs.length} runs + +
+ + +
+
+ )} +
+
+ + {/* Metrics Tab */} + + + +
+ Run Comparison +
+
+ X: + + +
+
+ Y: + + +
+
+
+
+ + {runs.length === 0 ? ( +
+ No runs in this sweep yet +
+ ) : metricNames.length > 0 ? ( + + + {metricNames.map((name) => ( + + {name} + + ))} + + {metricNames.map((name) => ( + + ({ id: r.id, name: r.name }))} + metricName={name} + height={400} + xAxisMode={xAxisMode} + yAxisScale={yAxisScale} + /> +

+ Click on a run in the legend to highlight it +

+
+ ))} +
+ ) : ( +
+ No metrics available yet +
+ )} +
+
+
+ + {/* Parameters Tab */} + +
+ + + + Parameter vs {sweep.metric_name} +
+ {parameterNames.map((name) => ( + + ))} +
+
+
+ + {selectedParam && parameterScatterData.length > 0 ? ( + + + + + + + { + const { cx, cy, payload } = props; + if (payload.isBest) { + return ( + + + + + ); + } + return ( + + ); + }} + /> + + + ) : ( +
+ {selectedParam + ? "No data available for this parameter" + : "Select a parameter to visualize"} +
+ )} +
+
+ + {/* Search Space */} + + + Search Space + + + + + + Parameter + Type + Range / Values + + + + {sweep.search_space.parameters.map((param) => ( + + + {param.name} + + + {param.type} + + + {param.type === "categorical" + ? param.values?.map((v) => String(v)).join(", ") + : `[${param.min}, ${param.max}]`} + + + ))} + +
+
+
+
+
+ + {/* Config Tab */} + +
+ + + Sweep Configuration + + +
+
+
Method
+
{sweep.method}
+
+
+
Metric
+
{sweep.metric_name}
+
+
+
Goal
+
{sweep.metric_goal}
+
+
+
Max Runs
+
+ {sweep.max_runs || "Unlimited"} +
+
+
+
+
+ + {sweep.early_stopping && ( + + + Early Stopping + + +
+
+
Method
+
+ {sweep.early_stopping.method} +
+
+
+
Min Steps
+
+ {sweep.early_stopping.min_steps} +
+
+
+
Warmup Runs
+
+ {sweep.early_stopping.warmup} +
+
+
+
+
+ )} + + {sweep.config && Object.keys(sweep.config).length > 0 && ( + + + Static Config + + +
+                    {JSON.stringify(sweep.config, null, 2)}
+                  
+
+
+ )} +
+
+
+
+ ); +} diff --git a/web/src/store/config-store.ts b/web/src/store/config-store.ts index 79f88a2..370f161 100644 --- a/web/src/store/config-store.ts +++ b/web/src/store/config-store.ts @@ -4,6 +4,7 @@ export interface AppConfig { mode: "local"; version: string; logdir?: string; + hasRemote?: boolean; features: { auth: boolean; teams: boolean;