package usecase import ( "context" "log/slog" "sort" "strings" "time" "github.com/google/uuid" "github.com/emil28092005/SciMesh/coordinator/internal/domain" "github.com/emil28092005/SciMesh/coordinator/internal/workloads" ) // AdminReadRepository is the bounded read projection behind the coordinator // admin console. Like UIReadRepository it exposes no storage paths or // credentials; unlike it, every method is admin-scoped (no owner filter). type AdminReadRepository interface { // ListJobsPaginated returns one page of jobs filtered by stored status; // an empty status returns all. total counts the filtered set (for the // pager). ListJobsPaginated(ctx context.Context, status string, limit, offset int) (jobs []domain.Job, total int, err error) // CountJobsByStatus powers the status tabs: every stored status, all jobs. CountJobsByStatus(ctx context.Context) (map[string]int, error) // TaskCountsByJobs aggregates task statuses per job for progress bars. TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) // JobCountsByDay buckets jobs created since `since` by UTC day // ("2006-01-02"). JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) // JobCountsByWorkload counts all jobs per workload name. JobCountsByWorkload(ctx context.Context) (map[string]int, error) // TaskStats totals shard execution: completed/failed counts and the mean // run duration of completed shards (seconds; 0 when nothing completed). TaskStats(ctx context.Context) (completed, failed int64, avgSeconds float64, err error) // ArtifactSizeByKind sums stored bytes per artifact kind. ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) // DatabaseSizeBytes reports the engine's own size figure (sqlite pages, // pg_database_size); 0 when the engine cannot say. DatabaseSizeBytes(ctx context.Context) (int64, error) } // AdminNodeInfo describes the running coordinator process to the admin // console. It is static for the process lifetime and assembled at startup. type AdminNodeInfo struct { Version string StartedAt time.Time Binary string Addr string DataDir string DBEngine string PublicURL string Userservice string // base URL; empty when the UI runs without user auth // WorkerToken reads the shared worker token for the Settings page. It is a // func so serve mode can read the token file lazily after provisioning. WorkerToken func() string } type AdminStorageView struct { DatasetsBytes int64 `json:"datasets_bytes"` ArtifactsBytes int64 `json:"artifacts_bytes"` DatabaseBytes int64 `json:"database_bytes"` } type AdminHealthView struct { Database string `json:"database"` // connected | error Reducer string `json:"reducer"` // idle | active Userservice string `json:"userservice"` // embedded | external | disabled } type AdminNodeView struct { Binary string `json:"binary"` Addr string `json:"addr"` DataDir string `json:"data_dir"` DBEngine string `json:"db_engine"` PublicURL string `json:"public_url"` } type AdminSystemView struct { Version string `json:"version"` StartedAt time.Time `json:"started_at"` UptimeSeconds int64 `json:"uptime_seconds"` ActiveJobs int `json:"active_jobs"` RunningJobs int `json:"running_jobs"` WaitingJobs int `json:"waiting_jobs"` WorkersOnline int `json:"workers_online"` WorkersBusy int `json:"workers_busy"` WorkersTotal int `json:"workers_total"` Storage AdminStorageView `json:"storage"` Health AdminHealthView `json:"health"` Node AdminNodeView `json:"node"` } // AdminJobCard is one row of the admin jobs table. Owner is a display string // resolved by the caller (email when the userservice is reachable, a short id // or "cluster token" otherwise). type AdminJobCard struct { ID string `json:"id"` Workload string `json:"workload"` Status string `json:"status"` OwnerID string `json:"owner_id,omitempty"` Owner string `json:"owner"` Total int `json:"total"` Completed int `json:"completed"` Failed int `json:"failed"` CreatedAt time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` } type AdminJobsView struct { Jobs []AdminJobCard `json:"jobs"` Total int `json:"total"` Page int `json:"page"` PerPage int `json:"per_page"` // Counts holds every stored status for the filter tabs (all jobs, not // just the current filter). Counts map[string]int `json:"counts"` } type AdminDayCount struct { Day string `json:"day"` Count int `json:"count"` } type AdminWorkloadCount struct { Workload string `json:"workload"` Count int `json:"count"` } type AdminMetricsView struct { JobsLast7Days int `json:"jobs_last_7_days"` JobsByDay []AdminDayCount `json:"jobs_by_day"` JobsByWorkload []AdminWorkloadCount `json:"jobs_by_workload"` ShardsCompleted int64 `json:"shards_completed"` ShardsFailed int64 `json:"shards_failed"` AvgShardSeconds float64 `json:"avg_shard_seconds"` FailureRate float64 `json:"failure_rate"` } // Admin answers the coordinator admin console from the bounded read model // plus process info supplied at startup. type Admin struct { read AdminReadRepository uiRead UIReadRepository workers WorkerRepository settings WorkloadSettingsRepository catalog *workloads.Catalog node AdminNodeInfo ready func(context.Context) error now func() time.Time log *slog.Logger audit func(ctx context.Context, action, detail string) } func NewAdmin(read AdminReadRepository, uiRead UIReadRepository, workers WorkerRepository, settings WorkloadSettingsRepository, catalog *workloads.Catalog, node AdminNodeInfo, ready func(context.Context) error, now func() time.Time) *Admin { if now == nil { now = time.Now } return &Admin{read: read, uiRead: uiRead, workers: workers, settings: settings, catalog: catalog, node: node, ready: ready, now: now} } // WithAuditLog attaches an audit sink for sensitive actions (token reveal). // Without it the admin usecase stays silent about them. func (a *Admin) WithAuditLog(log *slog.Logger, audit func(ctx context.Context, action, detail string)) *Admin { a.log = log a.audit = audit return a } func (a *Admin) revealToken(ctx context.Context, actor string) string { if a.node.WorkerToken != nil { return a.node.WorkerToken() } return "" } func (a *Admin) System(ctx context.Context) (AdminSystemView, error) { counts, err := a.read.CountJobsByStatus(ctx) if err != nil { return AdminSystemView{}, err } workers, err := a.uiRead.ListWorkers(ctx, 100) if err != nil { return AdminSystemView{}, err } sizes, err := a.read.ArtifactSizeByKind(ctx) if err != nil { return AdminSystemView{}, err } dbSize, err := a.read.DatabaseSizeBytes(ctx) if err != nil { return AdminSystemView{}, err } out := AdminSystemView{ Version: a.node.Version, StartedAt: a.node.StartedAt, WaitingJobs: counts[string(domain.JobPending)], RunningJobs: counts[string(domain.JobRunning)] + counts[string(domain.JobReducing)], } out.ActiveJobs = out.WaitingJobs + out.RunningJobs out.UptimeSeconds = int64(a.now().Sub(a.node.StartedAt).Seconds()) if out.UptimeSeconds < 0 { out.UptimeSeconds = 0 } for _, w := range workers { out.WorkersTotal++ switch w.Status { case domain.WorkerOnline: out.WorkersOnline++ case domain.WorkerBusy: out.WorkersOnline++ out.WorkersBusy++ } } for kind, size := range sizes { if kind == string(domain.ArtifactInput) { out.Storage.DatasetsBytes += size } else { out.Storage.ArtifactsBytes += size } } out.Storage.DatabaseBytes = dbSize out.Health.Database = "connected" if a.ready != nil { if err := a.ready(ctx); err != nil { out.Health.Database = "error" } } out.Health.Reducer = "idle" if counts[string(domain.JobReducing)] > 0 { out.Health.Reducer = "active" } out.Health.Userservice = "disabled" if a.node.Userservice != "" { out.Health.Userservice = "external" // The embedded userservice always binds the loopback interface. if strings.Contains(a.node.Userservice, "127.0.0.1") || strings.Contains(a.node.Userservice, "localhost") { out.Health.Userservice = "embedded" } } out.Node = AdminNodeView{ Binary: a.node.Binary, Addr: a.node.Addr, DataDir: a.node.DataDir, DBEngine: a.node.DBEngine, PublicURL: a.node.PublicURL, } return out, nil } // Jobs returns one page of the admin jobs table. The owner emails map may be // nil; cards then fall back to a short id or "cluster token". func (a *Admin) Jobs(ctx context.Context, status string, page, perPage int, ownerEmails map[uuid.UUID]string) (AdminJobsView, error) { if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } jobs, total, err := a.read.ListJobsPaginated(ctx, status, perPage, (page-1)*perPage) if err != nil { return AdminJobsView{}, err } counts, err := a.read.CountJobsByStatus(ctx) if err != nil { return AdminJobsView{}, err } jobIDs := make([]uuid.UUID, 0, len(jobs)) for _, job := range jobs { jobIDs = append(jobIDs, job.ID) } taskCounts, err := a.read.TaskCountsByJobs(ctx, jobIDs) if err != nil { return AdminJobsView{}, err } out := AdminJobsView{ Jobs: make([]AdminJobCard, 0, len(jobs)), Total: total, Page: page, PerPage: perPage, Counts: counts, } for _, job := range jobs { tc := taskCounts[job.ID] card := AdminJobCard{ ID: job.ID.String(), Workload: job.Workload, CreatedAt: job.CreatedAt, CompletedAt: job.CompletedAt, Owner: "cluster token", } var pending, leased, cancelled int for status, n := range tc { card.Total += n switch domain.TaskStatus(status) { case domain.TaskCompleted: card.Completed = n case domain.TaskFailed: card.Failed = n case domain.TaskPending: pending = n case domain.TaskLeased, domain.TaskRunning: leased += n case domain.TaskCancelled: cancelled = n } } // Derive the status exactly like the operator dashboard does, so the // two views never disagree about the same job. progress := domain.JobProgress{Job: job, Total: card.Total, Pending: pending, Leased: leased, Done: card.Completed, Failed: card.Failed, Cancelled: cancelled} card.Status = string(progress.DeriveStatus()) if job.OwnerID != nil { card.OwnerID = job.OwnerID.String() card.Owner = "user " + shortID(job.OwnerID.String()) if email, ok := ownerEmails[*job.OwnerID]; ok && email != "" { card.Owner = email } } out.Jobs = append(out.Jobs, card) } return out, nil } func (a *Admin) Metrics(ctx context.Context) (AdminMetricsView, error) { since := a.now().Add(-6 * 24 * time.Hour).Truncate(24 * time.Hour) byDay, err := a.read.JobCountsByDay(ctx, since) if err != nil { return AdminMetricsView{}, err } byWorkload, err := a.read.JobCountsByWorkload(ctx) if err != nil { return AdminMetricsView{}, err } completed, failed, avg, err := a.read.TaskStats(ctx) if err != nil { return AdminMetricsView{}, err } out := AdminMetricsView{ JobsByDay: make([]AdminDayCount, 0, 7), JobsByWorkload: make([]AdminWorkloadCount, 0, len(byWorkload)), ShardsCompleted: completed, ShardsFailed: failed, AvgShardSeconds: avg, } if completed+failed > 0 { out.FailureRate = float64(failed) / float64(completed+failed) } for i := 0; i < 7; i++ { day := since.Add(time.Duration(i) * 24 * time.Hour).UTC().Format("2006-01-02") count := byDay[day] out.JobsByDay = append(out.JobsByDay, AdminDayCount{Day: day, Count: count}) out.JobsLast7Days += count } for workload, count := range byWorkload { out.JobsByWorkload = append(out.JobsByWorkload, AdminWorkloadCount{Workload: workload, Count: count}) } sort.Slice(out.JobsByWorkload, func(i, j int) bool { if out.JobsByWorkload[i].Count != out.JobsByWorkload[j].Count { return out.JobsByWorkload[i].Count > out.JobsByWorkload[j].Count } return out.JobsByWorkload[i].Workload < out.JobsByWorkload[j].Workload }) return out, nil } // AdminWorkerCard is one row of the admin workers table. type AdminWorkerCard struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` Capabilities []string `json:"capabilities"` Trust string `json:"trust"` OwnerID string `json:"owner_id,omitempty"` Owner string `json:"owner"` Completed int `json:"completed"` LastHeartbeatAt time.Time `json:"last_heartbeat_at"` } type AdminWorkersView struct { Workers []AdminWorkerCard `json:"workers"` } // Workers lists the whole fleet for the admin console. Owner emails are // resolved through the same map as the jobs table (userservice-backed). func (a *Admin) Workers(ctx context.Context, ownerEmails map[uuid.UUID]string) (AdminWorkersView, error) { workers, err := a.uiRead.ListWorkers(ctx, 100) if err != nil { return AdminWorkersView{}, err } out := AdminWorkersView{Workers: make([]AdminWorkerCard, 0, len(workers))} for _, w := range workers { card := AdminWorkerCard{ ID: w.ID.String(), Name: w.Name, Status: string(w.Status), Capabilities: w.Capabilities, Trust: string(w.TrustLevel), LastHeartbeatAt: w.LastHeartbeatAt, Owner: "cluster token", } if w.OwnerID != nil { card.OwnerID = w.OwnerID.String() card.Owner = "user " + shortID(w.OwnerID.String()) if email, ok := ownerEmails[*w.OwnerID]; ok && email != "" { card.Owner = email } } out.Workers = append(out.Workers, card) } return out, nil } // SetTrust reclassifies one worker (trusted/untrusted). func (a *Admin) SetTrust(ctx context.Context, id uuid.UUID, trusted bool) error { trust := domain.WorkerUntrusted if trusted { trust = domain.WorkerTrusted } return a.workers.SetTrust(ctx, id, trust) } // AdminWorkloadView is the catalog plus the persisted enable flag. type AdminWorkloadView struct { Name string `json:"name"` Description string `json:"description"` Reduction string `json:"reduction"` Parameters int `json:"parameters"` UploadReady bool `json:"upload_ready"` Enabled bool `json:"enabled"` DefaultOn bool `json:"default_on"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } type AdminWorkloadsView struct { Workloads []AdminWorkloadView `json:"workloads"` } // Workloads lists the catalog with persisted enable/disable overrides. func (a *Admin) Workloads(ctx context.Context) (AdminWorkloadsView, error) { if a.catalog == nil { return AdminWorkloadsView{}, domain.ErrInvalidInput } items := a.catalog.Items() overrides, err := a.settings.List(ctx) if err != nil { return AdminWorkloadsView{}, err } enabled := make(map[string]WorkloadSetting, len(overrides)) for _, s := range overrides { enabled[s.Workload] = s } out := AdminWorkloadsView{Workloads: make([]AdminWorkloadView, 0, len(items))} for _, item := range items { params := 0 if properties, ok := item.Parameters["properties"].(map[string]any); ok { params = len(properties) } view := AdminWorkloadView{ Name: item.Name, Description: item.Description, Reduction: item.Reduction, Parameters: params, UploadReady: item.UploadReady, Enabled: true, DefaultOn: true, } if s, ok := enabled[item.Name]; ok { view.Enabled = s.Enabled view.DefaultOn = false view.UpdatedAt = &s.UpdatedAt } out.Workloads = append(out.Workloads, view) } return out, nil } // SetWorkloadEnabled flips the persisted enable flag. An unknown workload is // rejected: the admin console must not invent catalog entries. func (a *Admin) SetWorkloadEnabled(ctx context.Context, name string, enabled bool) error { if a.catalog == nil || a.catalog.ByName(name) == nil { return domain.ErrInvalidInput } return a.settings.SetEnabled(ctx, name, enabled, a.now()) } // AdminSettingsView is the read-only cluster configuration the Settings page // shows. The token is never included; it is revealed only through // RevealWorkerToken, which audits. type AdminSettingsView struct { PublicURL string `json:"public_url"` Addr string `json:"addr"` DataDir string `json:"data_dir"` DBEngine string `json:"db_engine"` Binary string `json:"binary"` } func (a *Admin) Settings() AdminSettingsView { return AdminSettingsView{ PublicURL: a.node.PublicURL, Addr: a.node.Addr, DataDir: a.node.DataDir, DBEngine: a.node.DBEngine, Binary: a.node.Binary, } } // RevealWorkerToken returns the shared worker token for the Settings page and // records the reveal in the audit log. It must only be called for an admin // session. func (a *Admin) RevealWorkerToken(ctx context.Context, actor string) string { token := a.revealToken(ctx, actor) if a.audit != nil { a.audit(ctx, "worker token revealed", "by "+actor) } if a.log != nil { a.log.Warn("admin console revealed the worker token", "actor", actor) } return token }