Add coordinator admin console foundation (system, jobs, metrics)
This commit is contained in:
@@ -76,6 +76,7 @@ type storageDeps struct {
|
||||
workerRepo usecase.WorkerRepository
|
||||
artifactRepo usecase.ArtifactRepository
|
||||
uiReadRepo usecase.UIReadRepository
|
||||
adminReadRepo usecase.AdminReadRepository
|
||||
taskResultRepo usecase.TaskResultRepository
|
||||
statsRepo interface {
|
||||
Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error)
|
||||
@@ -172,6 +173,16 @@ func runWithConfig(cfg infra.Config) error {
|
||||
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
|
||||
Dashboard: usecase.NewDashboard(uiReadRepo, catalog),
|
||||
PreviewArtifact: usecase.NewPreviewArtifact(uiReadRepo, blobStore),
|
||||
Admin: usecase.NewAdmin(deps.adminReadRepo, uiReadRepo, usecase.AdminNodeInfo{
|
||||
Version: version,
|
||||
StartedAt: clk.Now(),
|
||||
Binary: executablePath(),
|
||||
Addr: cfg.Addr,
|
||||
DataDir: cfg.StorageDir,
|
||||
DBEngine: cfg.DatabaseEngine,
|
||||
PublicURL: cfg.PublicCoordinatorURL,
|
||||
Userservice: cfg.UserserviceURL,
|
||||
}, deps.ready, clk.Now),
|
||||
}
|
||||
|
||||
// Background reapers are tracked so shutdown can wait for them. Without this
|
||||
@@ -225,6 +236,16 @@ func runWithConfig(cfg infra.Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// executablePath resolves the running binary for the admin console's node
|
||||
// information, falling back to the invocation name.
|
||||
func executablePath() string {
|
||||
path, err := os.Executable()
|
||||
if err != nil || path == "" {
|
||||
return os.Args[0]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// openSQLite opens the embedded database and builds the sqlite repositories.
|
||||
func openSQLite(ctx context.Context, cfg infra.Config, log *slog.Logger) (*storageDeps, error) {
|
||||
if err := os.MkdirAll(cfg.StorageDir, 0o750); err != nil {
|
||||
@@ -242,6 +263,7 @@ func openSQLite(ctx context.Context, cfg infra.Config, log *slog.Logger) (*stora
|
||||
workerRepo: sqlite.NewWorkerRepo(db),
|
||||
artifactRepo: sqlite.NewArtifactRepo(db),
|
||||
uiReadRepo: sqlite.NewUIReadRepo(db),
|
||||
adminReadRepo: sqlite.NewAdminReadRepo(db),
|
||||
taskResultRepo: sqlite.NewTaskResultRepo(db),
|
||||
statsRepo: sqlite.NewStatsRepo(db),
|
||||
ready: func(ctx context.Context) error { return db.PingContext(ctx) },
|
||||
@@ -264,6 +286,7 @@ func openPostgres(ctx context.Context, cfg infra.Config, log *slog.Logger) (*sto
|
||||
workerRepo: postgres.NewWorkerRepo(pool),
|
||||
artifactRepo: postgres.NewArtifactRepo(pool),
|
||||
uiReadRepo: postgres.NewUIReadRepo(pool),
|
||||
adminReadRepo: postgres.NewAdminReadRepo(pool),
|
||||
taskResultRepo: postgres.NewTaskResultRepo(pool),
|
||||
statsRepo: postgres.NewStatsRepo(pool),
|
||||
ready: func(ctx context.Context) error { return pool.Ping(ctx) },
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// AdminReadRepo backs the coordinator admin console: paginated jobs, status
|
||||
// counters, metrics buckets and storage figures. Read-only.
|
||||
type AdminReadRepo struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewAdminReadRepo(pool *pgxpool.Pool) *AdminReadRepo { return &AdminReadRepo{pool: pool} }
|
||||
|
||||
var _ usecase.AdminReadRepository = (*AdminReadRepo)(nil)
|
||||
|
||||
func (r *AdminReadRepo) ListJobsPaginated(ctx context.Context, status string, limit, offset int) ([]domain.Job, int, error) {
|
||||
if limit < 1 || limit > 100 || offset < 0 {
|
||||
return nil, 0, domain.ErrInvalidInput
|
||||
}
|
||||
countQ := psql.Select("COUNT(*)").From("jobs")
|
||||
listQ := psql.Select(jobColumns...).From("jobs")
|
||||
if status != "" {
|
||||
countQ = countQ.Where(sq.Eq{"status": status})
|
||||
listQ = listQ.Where(sq.Eq{"status": status})
|
||||
}
|
||||
countSQL, args, err := countQ.ToSql()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var total int
|
||||
if err := conn(ctx, r.pool).QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count jobs: %w", err)
|
||||
}
|
||||
listSQL, args, err := listQ.OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).Offset(uint64(offset)).ToSql()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, listSQL, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list jobs paginated: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
jobs := make([]domain.Job, 0)
|
||||
for rows.Next() {
|
||||
var j domain.Job
|
||||
var statusRaw string
|
||||
if err := rows.Scan(
|
||||
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &statusRaw, &j.CreatedAt, &j.CompletedAt,
|
||||
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
|
||||
&j.OwnerID,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
j.Status = domain.JobStatus(statusRaw)
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, total, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) CountJobsByStatus(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, "SELECT status, COUNT(*) FROM jobs GROUP BY status")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count jobs by status: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int
|
||||
if err := rows.Scan(&status, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[status] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) {
|
||||
out := make(map[uuid.UUID]map[string]int, len(jobIDs))
|
||||
if len(jobIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
sql, args, err := psql.Select("job_id", "status", "COUNT(*)").From("tasks").
|
||||
Where(sq.Eq{"job_id": jobIDs}).GroupBy("job_id", "status").ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task counts by jobs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var jobID uuid.UUID
|
||||
var status string
|
||||
var count int
|
||||
if err := rows.Scan(&jobID, &status, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out[jobID] == nil {
|
||||
out[jobID] = make(map[string]int)
|
||||
}
|
||||
out[jobID][status] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.pool).Query(ctx,
|
||||
"SELECT to_char(date_trunc('day', created_at AT TIME ZONE 'UTC'), 'YYYY-MM-DD') AS day, COUNT(*) FROM jobs WHERE created_at >= $1 GROUP BY 1",
|
||||
since.UTC())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("job counts by day: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var day string
|
||||
var count int
|
||||
if err := rows.Scan(&day, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[day] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) JobCountsByWorkload(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, "SELECT workload, COUNT(*) FROM jobs GROUP BY workload")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("job counts by workload: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var workload string
|
||||
var count int
|
||||
if err := rows.Scan(&workload, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[workload] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) TaskStats(ctx context.Context) (int64, int64, float64, error) {
|
||||
var completed, failed int64
|
||||
var avgSeconds *float64
|
||||
err := conn(ctx, r.pool).QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0),
|
||||
AVG(CASE WHEN status = 'completed' AND started_at IS NOT NULL
|
||||
THEN EXTRACT(EPOCH FROM completed_at - started_at) END)::float8
|
||||
FROM tasks`).Scan(&completed, &failed, &avgSeconds)
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("task stats: %w", err)
|
||||
}
|
||||
var avg float64
|
||||
if avgSeconds != nil {
|
||||
avg = *avgSeconds
|
||||
}
|
||||
return completed, failed, avg, nil
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) {
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, "SELECT kind, COALESCE(SUM(size_bytes), 0) FROM artifacts GROUP BY kind")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("artifact sizes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var kind string
|
||||
var size int64
|
||||
if err := rows.Scan(&kind, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[kind] = size
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) DatabaseSizeBytes(ctx context.Context) (int64, error) {
|
||||
var size int64
|
||||
if err := conn(ctx, r.pool).QueryRow(ctx, "SELECT pg_database_size(current_database())").Scan(&size); err != nil {
|
||||
return 0, fmt.Errorf("database size: %w", err)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// AdminReadRepo backs the coordinator admin console: paginated jobs, status
|
||||
// counters, metrics buckets and storage figures. Read-only.
|
||||
type AdminReadRepo struct{ db *sql.DB }
|
||||
|
||||
func NewAdminReadRepo(db *sql.DB) *AdminReadRepo { return &AdminReadRepo{db: db} }
|
||||
|
||||
var _ usecase.AdminReadRepository = (*AdminReadRepo)(nil)
|
||||
|
||||
func (r *AdminReadRepo) ListJobsPaginated(ctx context.Context, status string, limit, offset int) ([]domain.Job, int, error) {
|
||||
if limit < 1 || limit > 100 || offset < 0 {
|
||||
return nil, 0, domain.ErrInvalidInput
|
||||
}
|
||||
where := ""
|
||||
args := []any{}
|
||||
if status != "" {
|
||||
where = " WHERE status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
var total int
|
||||
if err := conn(ctx, r.db).QueryRowContext(ctx, "SELECT COUNT(*) FROM jobs"+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count jobs: %w", err)
|
||||
}
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT "+jobColumns+" FROM jobs"+where+" ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
|
||||
append(args, limit, offset)...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list jobs paginated: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
jobs := make([]domain.Job, 0)
|
||||
for rows.Next() {
|
||||
job, err := scanJob(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
jobs = append(jobs, *job)
|
||||
}
|
||||
return jobs, total, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) CountJobsByStatus(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx, "SELECT status, COUNT(*) FROM jobs GROUP BY status")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count jobs by status: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int
|
||||
if err := rows.Scan(&status, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[status] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) {
|
||||
out := make(map[uuid.UUID]map[string]int, len(jobIDs))
|
||||
if len(jobIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
placeholders := make([]string, 0, len(jobIDs))
|
||||
args := make([]any, 0, len(jobIDs))
|
||||
for _, id := range jobIDs {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id.String())
|
||||
}
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT job_id, status, COUNT(*) FROM tasks WHERE job_id IN ("+strings.Join(placeholders, ", ")+") GROUP BY job_id, status",
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task counts by jobs: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var jobRaw, status string
|
||||
var count int
|
||||
if err := rows.Scan(&jobRaw, &status, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobID, err := uuid.Parse(jobRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task counts: parse job id: %w", err)
|
||||
}
|
||||
if out[jobID] == nil {
|
||||
out[jobID] = make(map[string]int)
|
||||
}
|
||||
out[jobID][status] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) {
|
||||
// created_at is unix nanos; the bucket is the UTC calendar day.
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT strftime('%Y-%m-%d', created_at / 1000000000, 'unixepoch') AS day, COUNT(*) FROM jobs WHERE created_at >= ? GROUP BY day",
|
||||
since.UTC().UnixNano())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("job counts by day: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var day string
|
||||
var count int
|
||||
if err := rows.Scan(&day, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[day] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) JobCountsByWorkload(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx, "SELECT workload, COUNT(*) FROM jobs GROUP BY workload")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("job counts by workload: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var workload string
|
||||
var count int
|
||||
if err := rows.Scan(&workload, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[workload] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) TaskStats(ctx context.Context) (int64, int64, float64, error) {
|
||||
var completed, failed int64
|
||||
var avgNanos sql.NullFloat64
|
||||
err := conn(ctx, r.db).QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0),
|
||||
AVG(CASE WHEN status = 'completed' AND started_at IS NOT NULL THEN completed_at - started_at END)
|
||||
FROM tasks`).Scan(&completed, &failed, &avgNanos)
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("task stats: %w", err)
|
||||
}
|
||||
return completed, failed, avgNanos.Float64 / 1e9, nil
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx, "SELECT kind, COALESCE(SUM(size_bytes), 0) FROM artifacts GROUP BY kind")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("artifact sizes: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var kind string
|
||||
var size int64
|
||||
if err := rows.Scan(&kind, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[kind] = size
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) DatabaseSizeBytes(ctx context.Context) (int64, error) {
|
||||
var pageCount, pageSize int64
|
||||
if err := conn(ctx, r.db).QueryRowContext(ctx, "PRAGMA page_count").Scan(&pageCount); err != nil {
|
||||
return 0, fmt.Errorf("page count: %w", err)
|
||||
}
|
||||
if err := conn(ctx, r.db).QueryRowContext(ctx, "PRAGMA page_size").Scan(&pageSize); err != nil {
|
||||
return 0, fmt.Errorf("page size: %w", err)
|
||||
}
|
||||
return pageCount * pageSize, nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
func TestAdminListJobsPaginatedAndCounts(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
jobRepo := NewJobRepo(db)
|
||||
adminRepo := NewAdminReadRepo(db)
|
||||
|
||||
jobs := make([]*domain.Job, 5)
|
||||
for i := range jobs {
|
||||
jobs[i] = seedJob(t, db, 2)
|
||||
}
|
||||
// Two completed, two running, one pending.
|
||||
if err := jobRepo.UpdateStatus(ctx, jobs[0].ID, domain.JobCompleted, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := jobRepo.UpdateStatus(ctx, jobs[1].ID, domain.JobCompleted, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := jobRepo.UpdateStatus(ctx, jobs[2].ID, domain.JobRunning, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := jobRepo.UpdateStatus(ctx, jobs[3].ID, domain.JobRunning, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all, total, err := adminRepo.ListJobsPaginated(ctx, "", 100, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(all) != 5 {
|
||||
t.Errorf("all: total=%d len=%d, want 5/5", total, len(all))
|
||||
}
|
||||
completed, total, err := adminRepo.ListJobsPaginated(ctx, "completed", 100, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(completed) != 2 {
|
||||
t.Errorf("completed: total=%d len=%d, want 2/2", total, len(completed))
|
||||
}
|
||||
page, total, err := adminRepo.ListJobsPaginated(ctx, "", 2, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(page) != 2 {
|
||||
t.Errorf("page: total=%d len=%d, want 5/2", total, len(page))
|
||||
}
|
||||
|
||||
counts, err := adminRepo.CountJobsByStatus(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counts["completed"] != 2 || counts["running"] != 2 || counts["pending"] != 1 {
|
||||
t.Errorf("counts = %v, want completed=2 running=2 pending=1", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTaskCountsByJobs(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
job := seedJob(t, db, 3)
|
||||
if _, err := db.ExecContext(ctx, "UPDATE tasks SET status = 'completed', result_artifact_id = ? WHERE chunk_index = 0 AND job_id = ?", uuid.NewString(), job.ID.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "UPDATE tasks SET status = 'failed' WHERE chunk_index = 1 AND job_id = ?", job.ID.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
counts, err := NewAdminReadRepo(db).TaskCountsByJobs(ctx, []uuid.UUID{job.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := counts[job.ID]
|
||||
if got["completed"] != 1 || got["failed"] != 1 || got["pending"] != 1 {
|
||||
t.Errorf("task counts = %v, want completed=1 failed=1 pending=1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobCountsByDay(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
job := seedJob(t, db, 1)
|
||||
// Move the seed job to two days ago; create two more today.
|
||||
old := fixedTime().Add(-48 * time.Hour)
|
||||
if _, err := db.ExecContext(ctx, "UPDATE jobs SET created_at = ? WHERE id = ?", old.UnixNano(), job.ID.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedJob(t, db, 1)
|
||||
seedJob(t, db, 1)
|
||||
|
||||
repo := NewAdminReadRepo(db)
|
||||
counts, err := repo.JobCountsByDay(ctx, fixedTime().Add(-6*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
today := fixedTime().UTC().Format("2006-01-02")
|
||||
oldDay := old.UTC().Format("2006-01-02")
|
||||
if counts[today] != 2 {
|
||||
t.Errorf("today count = %d, want 2 (got %v)", counts[today], counts)
|
||||
}
|
||||
if counts[oldDay] != 1 {
|
||||
t.Errorf("old day count = %d, want 1 (got %v)", counts[oldDay], counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTaskStatsAndStorage(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewAdminReadRepo(db)
|
||||
|
||||
// One completed task with a known duration, one failed.
|
||||
job := seedJob(t, db, 2)
|
||||
start := fixedTime().Add(-2 * time.Minute)
|
||||
done := fixedTime().Add(-90 * time.Second)
|
||||
queries := []string{
|
||||
"UPDATE tasks SET status='completed', result_artifact_id=?, started_at=?, completed_at=? WHERE job_id=? AND chunk_index=0",
|
||||
"UPDATE tasks SET status='failed' WHERE job_id=? AND chunk_index=1",
|
||||
}
|
||||
for i, q := range queries {
|
||||
args := []any{uuid.NewString(), start.UnixNano(), done.UnixNano(), job.ID.String()}
|
||||
if i == 1 {
|
||||
args = []any{job.ID.String()}
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, q, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
completed, failed, avg, err := repo.TaskStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completed != 1 || failed != 1 {
|
||||
t.Errorf("stats = completed %d failed %d, want 1/1", completed, failed)
|
||||
}
|
||||
if avg < 29 || avg > 31 {
|
||||
t.Errorf("avg duration = %.1fs, want ~30s", avg)
|
||||
}
|
||||
|
||||
// Artifact sizes by kind.
|
||||
for _, kind := range []string{"input", "shard", "final_result"} {
|
||||
if _, err := db.ExecContext(ctx, "INSERT INTO artifacts (id, job_id, kind, filename, storage_key, content_type, size_bytes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
uuid.NewString(), job.ID.String(), kind, kind+".csv", "key-"+kind, "text/csv", int64(len(kind)*1000), fixedTime().UnixNano()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sizes, err := repo.ArtifactSizeByKind(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sizes["input"] != 5000 || sizes["shard"] != 5000 || sizes["final_result"] != 12000 {
|
||||
t.Errorf("sizes = %v", sizes)
|
||||
}
|
||||
dbBytes, err := repo.DatabaseSizeBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dbBytes <= 0 {
|
||||
t.Errorf("database size = %d, want > 0", dbBytes)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ type UseCases struct {
|
||||
GetTaskInput *usecase.GetTaskInput
|
||||
Dashboard *usecase.Dashboard
|
||||
PreviewArtifact *usecase.PreviewArtifact
|
||||
Admin *usecase.Admin
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -177,6 +178,10 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
// Admin panel: session + admin role.
|
||||
ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin))
|
||||
ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin))
|
||||
// Admin console APIs: session + admin role, bounded read models.
|
||||
ui.Handle("GET /ui/admin/api/system", chain(http.HandlerFunc(s.handleUIAdminSystemJSON), gate, requireAdmin))
|
||||
ui.Handle("GET /ui/admin/api/jobs", chain(http.HandlerFunc(s.handleUIAdminJobsJSON), gate, requireAdmin))
|
||||
ui.Handle("GET /ui/admin/api/metrics", chain(http.HandlerFunc(s.handleUIAdminMetricsJSON), gate, requireAdmin))
|
||||
} else {
|
||||
for _, rt := range app {
|
||||
ui.HandleFunc(rt.pattern, rt.handler)
|
||||
|
||||
@@ -2,45 +2,389 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin · SciMesh</title>
|
||||
<style>
|
||||
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:820px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.top{display:flex;justify-content:space-between;align-items:center;gap:12px}.eyebrow{margin:0;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:6px 0 0;color:#f4f8ff;font-size:clamp(1.8rem,4vw,2.6rem);letter-spacing:-.04em}.lead{max-width:640px;margin:10px 0 0;color:#aabed9}.card{margin-top:24px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:22px}.card h2{margin:0 0 4px;color:#f1f6ff;font-size:1.1rem}.card p{margin:0;color:#9fb3cf;font-size:.92rem}label{display:block;margin:16px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.actions{display:flex;flex-wrap:wrap;gap:9px;margin-top:16px}.btn{border:0;border-radius:9px;padding:10px 14px;font:inherit;font-weight:800;cursor:pointer}.btn-primary{background:#67e3b8;color:#062018}.btn-muted{background:#23344d;color:#dce8ff}.notice{margin-top:16px;border-radius:10px;padding:11px 13px;font-weight:700}.ok{background:#123f34;color:#76efb5}.err{background:#552334;color:#ff9bad}.muted{color:#8ba2c2}.hint{margin-top:4px;color:#92a9c6;font-size:.85rem}</style>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SciMesh · Coordinator Admin</title>
|
||||
<style>
|
||||
:root{--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;color-scheme:dark}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
input,select{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:8px;padding:8px 11px;outline:none}
|
||||
input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||
code{font-family:var(--mono);font-size:.86em}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
.layout{display:flex;min-height:100vh}
|
||||
.sidebar{position:sticky;top:0;height:100vh;width:232px;flex:none;display:flex;flex-direction:column;background:var(--panel);border-right:1px solid var(--border-soft)}
|
||||
.brand{display:flex;align-items:center;gap:11px;padding:20px 20px 18px;border-bottom:1px solid var(--border-soft)}
|
||||
.brand-mark{display:grid;place-items:center;width:32px;height:32px;border-radius:9px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 14px #5b8cff40}
|
||||
.brand-mark svg{width:17px;height:17px;stroke:#fff}
|
||||
.brand-name{font-weight:700;font-size:14.5px;letter-spacing:-.01em}
|
||||
.brand-sub{font-size:11px;color:var(--text-3);letter-spacing:.02em}
|
||||
.nav{flex:1;overflow-y:auto;padding:14px 12px}
|
||||
.nav-label{margin:16px 10px 6px;font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text-3)}
|
||||
.nav-label:first-child{margin-top:0}
|
||||
.nav-item{display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;border-radius:8px;color:var(--text-2);font-weight:500;text-align:left;transition:background .12s,color .12s}
|
||||
.nav-item svg{width:16px;height:16px;stroke:currentColor;flex:none}
|
||||
.nav-item:hover{background:var(--panel-2);color:var(--text)}
|
||||
.nav-item.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
|
||||
.nav-item .count{margin-left:auto;font-size:11px;font-weight:600;color:var(--text-3);background:var(--panel-2);border-radius:99px;padding:1px 7px}
|
||||
.nav-item.active .count{color:var(--accent);background:#5b8cff26}
|
||||
.side-foot{padding:14px;border-top:1px solid var(--border-soft)}
|
||||
.user-chip{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:9px;background:var(--panel-2)}
|
||||
.avatar{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,#3fce8a,#2ea56c);color:#08130d;font-weight:800;font-size:12px;flex:none}
|
||||
.user-chip b{display:block;font-size:12.5px;line-height:1.25}
|
||||
.user-chip span{display:block;font-size:11px;color:var(--text-3)}
|
||||
.back-link{display:block;margin-top:9px;padding:7px 10px;color:var(--text-3);font-size:12.5px;text-decoration:none;border-radius:8px}
|
||||
.back-link:hover{color:var(--text);background:var(--panel-2)}
|
||||
.main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.topbar{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 32px;background:#0b0e13e6;backdrop-filter:blur(10px);border-bottom:1px solid var(--border-soft)}
|
||||
.topbar h1{font-size:17px;font-weight:700;letter-spacing:-.015em}
|
||||
.topbar p{font-size:12.5px;color:var(--text-3);margin-top:1px}
|
||||
.env-badge{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-2);border:1px solid var(--border);border-radius:99px;padding:5px 12px;background:var(--panel)}
|
||||
.env-badge i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green)}
|
||||
.content{flex:1;padding:26px 32px 60px;max-width:1120px;width:100%;margin:0 auto}
|
||||
.page{display:none}.page.active{display:block;animation:fade .18s ease}
|
||||
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1}}
|
||||
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px}
|
||||
.card-pad{padding:20px}
|
||||
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:15px 20px;border-bottom:1px solid var(--border-soft)}
|
||||
.card-head h3{font-size:13.5px;font-weight:650}
|
||||
.card-head span{font-size:12px;color:var(--text-3)}
|
||||
.grid-kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:14px}
|
||||
.kpi{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px;padding:16px 18px}
|
||||
.kpi .k-label{display:flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text-3)}
|
||||
.kpi .k-value{margin-top:8px;font-size:26px;font-weight:700;letter-spacing:-.03em;line-height:1}
|
||||
.kpi .k-sub{margin-top:6px;font-size:12px;color:var(--text-2)}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:99px;padding:3px 10px;font-size:11.5px;font-weight:650;white-space:nowrap}
|
||||
.pill i{width:6px;height:6px;border-radius:50%;background:currentColor}
|
||||
.pill-success{background:var(--green-soft);color:var(--green)}
|
||||
.pill-active{background:var(--accent-soft);color:var(--accent)}
|
||||
.pill-waiting{background:#ffffff12;color:var(--text-2)}
|
||||
.pill-danger{background:var(--red-soft);color:var(--red)}
|
||||
.pill-amber{background:var(--amber-soft);color:var(--amber)}
|
||||
.btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:8px 14px;font-weight:600;font-size:13px;border:1px solid transparent;transition:filter .12s,background .12s}
|
||||
.btn svg{width:14px;height:14px;stroke:currentColor}
|
||||
.btn-primary{background:var(--accent);color:#0a1222}
|
||||
.btn-ghost{background:var(--panel-2);border-color:var(--border);color:var(--text-2)}
|
||||
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
|
||||
.btn-danger{background:var(--red-soft);color:var(--red)}
|
||||
.btn-sm{padding:5px 10px;font-size:12px;border-radius:7px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{padding:10px 20px;text-align:left;font-size:11px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
|
||||
td{padding:12px 20px;border-bottom:1px solid var(--border-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr{transition:background .1s}
|
||||
tbody tr:hover{background:var(--panel-2)}
|
||||
.t-main{font-weight:600;font-size:13.5px}
|
||||
.t-sub{font-size:11.5px;color:var(--text-3);font-family:var(--mono)}
|
||||
.bar{height:5px;width:130px;border-radius:99px;background:#ffffff10;overflow:hidden}
|
||||
.bar span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#5b8cff,#3fce8a)}
|
||||
.bar-label{font-size:11.5px;color:var(--text-2);font-family:var(--mono);margin-top:5px}
|
||||
.tabs{display:flex;gap:4px;padding:4px;background:var(--panel);border:1px solid var(--border-soft);border-radius:10px;width:max-content;margin-bottom:14px;flex-wrap:wrap}
|
||||
.tab{padding:6px 13px;border-radius:7px;font-size:12.5px;font-weight:600;color:var(--text-2)}
|
||||
.tab:hover{color:var(--text)}
|
||||
.tab.active{background:var(--panel-2);color:var(--text);box-shadow:inset 0 0 0 1px var(--border)}
|
||||
.tab .n{color:var(--text-3);font-weight:500;margin-left:5px}
|
||||
.tab.active .n{color:var(--accent)}
|
||||
.section-title{margin:26px 0 12px;font-size:13px;font-weight:700;letter-spacing:-.01em;color:var(--text)}
|
||||
.section-title:first-child{margin-top:0}
|
||||
.section-note{font-size:12px;color:var(--text-3);margin:-8px 0 12px}
|
||||
.kv{display:grid;grid-template-columns:210px 1fr;row-gap:0}
|
||||
.kv dt{padding:11px 20px;font-size:12.5px;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
|
||||
.kv dd{padding:11px 20px;font-size:13px;border-bottom:1px solid var(--border-soft)}
|
||||
.kv dt:last-of-type,.kv dd:last-of-type{border-bottom:0}
|
||||
.stack{display:grid;gap:14px}
|
||||
.split{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.storage-bar{display:flex;height:10px;border-radius:99px;overflow:hidden;margin:14px 20px 6px}
|
||||
.storage-bar div{height:100%}
|
||||
.legend{display:flex;gap:20px;padding:10px 20px 18px;flex-wrap:wrap}
|
||||
.legend span{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2)}
|
||||
.legend i{width:9px;height:9px;border-radius:3px}
|
||||
.footer-row{display:flex;align-items:center;justify-content:space-between;padding:11px 20px;font-size:12px;color:var(--text-3)}
|
||||
.pager{display:flex;gap:4px}
|
||||
.pager button{width:26px;height:26px;border-radius:7px;font-size:12px;color:var(--text-2)}
|
||||
.pager button.cur{background:var(--accent-soft);color:var(--accent);font-weight:700}
|
||||
.pager button:disabled{opacity:.35;cursor:default}
|
||||
.chart{width:100%;height:auto;display:block}
|
||||
.chart-bar{fill:#2c3a52;rx:4}
|
||||
.chart-bar.hot{fill:var(--accent)}
|
||||
.chart-grid{stroke:#ffffff08}
|
||||
.chart-label{font:10px var(--mono);fill:var(--text-3)}
|
||||
.placeholder{border:1px dashed #2c3a52;border-radius:13px;padding:34px 24px;text-align:center;color:var(--text-2)}
|
||||
.placeholder b{display:block;color:var(--text);margin-bottom:6px}
|
||||
.empty{padding:26px;text-align:center;color:var(--text-3);font-size:13px}
|
||||
.sec-label{font-size:11px;font-weight:650;letter-spacing:.06em;text-transform:uppercase;color:var(--text-3);margin:14px 20px 6px}
|
||||
.sec-label:first-child{margin-top:18px}
|
||||
.field-row{display:flex;gap:10px;align-items:center;margin-bottom:10px}
|
||||
.field-row label{font-size:12px;color:var(--text-2);width:200px;flex:none}
|
||||
@media(max-width:960px){.sidebar{display:none}.grid-kpi,.split{grid-template-columns:1fr 1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Admin panel</p><h1>User & run control</h1></div>
|
||||
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><a href="/ui/profile">Profile</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
|
||||
</header>
|
||||
<p class="lead">Signed in as <strong>{{.Role}}</strong>. Promote or verify a user by their id, and control every job from the dashboard.</p>
|
||||
<div class="layout">
|
||||
|
||||
{{if .Msg}}<div class="notice ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Error}}<div class="notice err">{{.Error}}</div>{{end}}
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
|
||||
<div><div class="brand-name">SciMesh</div><div class="brand-sub">Coordinator Admin</div></div>
|
||||
</div>
|
||||
<nav class="nav" id="nav">
|
||||
<div class="nav-label">Operate</div>
|
||||
<button class="nav-item active" data-page="system"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="5" rx="2"/><rect x="13" y="10" width="8" height="11" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/></svg>System</button>
|
||||
<button class="nav-item" data-page="jobs"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Jobs</button>
|
||||
<button class="nav-item" data-page="workers"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>Workers</button>
|
||||
<div class="nav-label">Access</div>
|
||||
<button class="nav-item" data-page="users"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="9" cy="8" r="3.2"/><path d="M3.5 19c.7-3 2.9-4.5 5.5-4.5s4.8 1.5 5.5 4.5"/><circle cx="17" cy="9" r="2.4"/><path d="M15.5 14.6c2.6.2 4.3 1.7 5 4.4"/></svg>Users & keys</button>
|
||||
<div class="nav-label">Platform</div>
|
||||
<button class="nav-item" data-page="workloads"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>Workloads</button>
|
||||
<button class="nav-item" data-page="metrics"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 19V5M4 19h16"/><path d="M8 15v-4M12 15V7M16 15v-6M20 15V9"/></svg>Metrics</button>
|
||||
<button class="nav-item" data-page="settings"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6c.1-.4.1-.8.1-1.2z"/></svg>Settings</button>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<div class="user-chip"><div class="avatar">{{.Role}}</div><div><b>Signed in as {{.Role}}</b><span>cluster administrator</span></div></div>
|
||||
<a class="back-link" href="/ui">← Back to control room</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="card">
|
||||
<h2>Manage a user</h2>
|
||||
<p>Paste the user id (the JWT <code>sub</code> / the value shown at registration). Actions are applied immediately.</p>
|
||||
<form method="post" action="/ui/admin/user-action">
|
||||
<label for="user_id">User id</label>
|
||||
<input id="user_id" name="user_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required>
|
||||
<p class="hint">Promote makes them an admin; Verify marks them a trusted contributor (their workers skip quorum).</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" name="action" value="promote" type="submit">Make admin</button>
|
||||
<button class="btn btn-muted" name="action" value="demote" type="submit">Remove admin</button>
|
||||
<button class="btn btn-primary" name="action" value="verify" type="submit">Verify</button>
|
||||
<button class="btn btn-muted" name="action" value="unverify" type="submit">Unverify</button>
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div><h1 id="page-title">System</h1><p id="page-sub">Cluster state and node information</p></div>
|
||||
<div class="env-badge"><i></i><span id="env-label">admin console</span></div>
|
||||
</header>
|
||||
<div class="content">
|
||||
|
||||
<!-- ═══ SYSTEM ═══ -->
|
||||
<section class="page active" id="page-system">
|
||||
<div class="grid-kpi">
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/></svg>Version</div><div class="k-value" id="k-version">—</div><div class="k-sub" id="k-version-sub">loading…</div></div>
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>Uptime</div><div class="k-value" id="k-uptime">—</div><div class="k-sub" id="k-started">loading…</div></div>
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Active jobs</div><div class="k-value" id="k-active">—</div><div class="k-sub" id="k-active-sub">loading…</div></div>
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/></svg>Workers online</div><div class="k-value" id="k-workers">—</div><div class="k-sub" id="k-workers-sub">loading…</div></div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Storage usage</h3><span id="storage-total">—</span></div>
|
||||
<div class="storage-bar" id="storage-bar"><div style="width:0;background:#5b8cff"></div><div style="width:0;background:#7c5cff"></div><div style="width:0;background:#3fce8a"></div></div>
|
||||
<div class="legend">
|
||||
<span><i style="background:#5b8cff"></i>Datasets · <b id="storage-datasets">—</b></span>
|
||||
<span><i style="background:#7c5cff"></i>Artifacts · <b id="storage-artifacts">—</b></span>
|
||||
<span><i style="background:#3fce8a"></i>Database · <b id="storage-db">—</b></span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Health</h3><span id="health-state">—</span></div>
|
||||
<dl class="kv">
|
||||
<dt>Database</dt><dd id="h-db">—</dd>
|
||||
<dt>Userservice</dt><dd id="h-users">—</dd>
|
||||
<dt>Reducer</dt><dd id="h-reducer">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">Node information</div>
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Binary</dt><dd><code id="n-binary">—</code></dd>
|
||||
<dt>Listen address</dt><dd><code id="n-addr">—</code></dd>
|
||||
<dt>Data directory</dt><dd><code id="n-datadir">—</code></dd>
|
||||
<dt>Database engine</dt><dd id="n-engine">—</dd>
|
||||
<dt>Public URL</dt><dd><code id="n-public">—</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Jobs & tasks</h2>
|
||||
<p>As an admin you already see <strong>every user's jobs</strong> on the dashboard, with per-task status and job cancellation. A regular user sees only their own.</p>
|
||||
<div class="actions"><a class="btn btn-muted" href="/ui" style="text-decoration:none">Open the dashboard →</a></div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- ═══ JOBS ═══ -->
|
||||
<section class="page" id="page-jobs">
|
||||
<div class="tabs" id="job-tabs"></div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Job</th><th>Workload</th><th>Owner</th><th>Status</th><th>Progress</th><th>Submitted</th></tr></thead>
|
||||
<tbody id="job-rows"></tbody>
|
||||
</table>
|
||||
<div class="footer-row"><span id="job-range">—</span><div class="pager"><button id="pg-prev" aria-label="previous">‹</button><button id="pg-next" aria-label="next">›</button></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ WORKERS (M2) ═══ -->
|
||||
<section class="page" id="page-workers">
|
||||
<div class="card"><div class="card-head"><h3>Workers</h3><span>milestone M2</span></div><div class="placeholder"><b>Trust management lands with milestone M2</b>Worker list, trust dropdown and heartbeat overview are wired next. The dashboard already shows the live fleet.</div></div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ USERS & KEYS (M2) ═══ -->
|
||||
<section class="page" id="page-users">
|
||||
<div class="section-title">Quick user action</div>
|
||||
<div class="card">
|
||||
<form method="post" action="/ui/admin/user-action" style="padding:16px 20px">
|
||||
<div class="field-row"><label for="user_id">User id</label><input id="user_id" name="user_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required style="flex:1"></div>
|
||||
<div style="display:flex;gap:8px;margin-left:210px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" name="action" value="promote" type="submit">Make admin</button>
|
||||
<button class="btn btn-ghost" name="action" value="demote" type="submit">Remove admin</button>
|
||||
<button class="btn btn-primary" name="action" value="verify" type="submit">Verify</button>
|
||||
<button class="btn btn-ghost" name="action" value="unverify" type="submit">Unverify</button>
|
||||
</div>
|
||||
{{if .Msg}}<div style="margin-left:210px;margin-top:10px;color:var(--green);font-size:13px">✓ {{.Msg}}</div>{{end}}
|
||||
{{if .Error}}<div style="margin-left:210px;margin-top:10px;color:var(--red);font-size:13px">✗ {{.Error}}</div>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
<div class="section-title">Accounts and worker keys</div>
|
||||
<div class="card"><div class="placeholder"><b>User list and key management land with milestone M2</b>The userservice gains admin list endpoints; the console gets tables, role selects and key revoke.</div></div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ WORKLOADS (M2) ═══ -->
|
||||
<section class="page" id="page-workloads">
|
||||
<div class="card"><div class="placeholder"><b>Workload enable/disable lands with milestone M2</b>The catalog is already served to the job form; persisted on/off switches arrive with the settings migration.</div></div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ METRICS ═══ -->
|
||||
<section class="page" id="page-metrics">
|
||||
<div class="grid-kpi">
|
||||
<div class="kpi"><div class="k-label">Jobs · 7 days</div><div class="k-value" id="m-jobs7">—</div><div class="k-sub">created in the last week</div></div>
|
||||
<div class="kpi"><div class="k-label">Shards completed</div><div class="k-value" id="m-shards">—</div><div class="k-sub" id="m-shards-sub">across all workers</div></div>
|
||||
<div class="kpi"><div class="k-label">Avg shard time</div><div class="k-value" id="m-avg">—</div><div class="k-sub">completed shards only</div></div>
|
||||
<div class="kpi"><div class="k-label">Failure rate</div><div class="k-value" id="m-failrate">—</div><div class="k-sub" id="m-failrate-sub">—</div></div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Jobs per day</h3><span>last 7 days</span></div>
|
||||
<div style="padding:16px 20px 10px" id="chart-days"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Jobs by workload</h3><span>all time</span></div>
|
||||
<dl class="kv" id="chart-workloads"></dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SETTINGS (M2) ═══ -->
|
||||
<section class="page" id="page-settings">
|
||||
<div class="card"><div class="placeholder"><b>Cluster settings land with milestone M2</b>Worker token reveal, public URL and storage settings follow in the next milestone.</div></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const titles={system:['System','Cluster state and node information'],jobs:['Jobs','Every computation, filterable and paginated'],workers:['Workers','Fleet overview and trust management'],users:['Users & keys','Accounts, roles and worker keys'],workloads:['Workloads','Catalog entries and availability'],metrics:['Metrics','Throughput and reliability, last 7 days'],settings:['Settings','Cluster, storage and security']};
|
||||
const statusLabel={pending:'Waiting',leased:'Assigned',running:'Running',reducing:'Merging',completed:'Completed',failed:'Failed',cancelled:'Cancelled'};
|
||||
const statusClass={pending:'pill-waiting',leased:'pill-active',running:'pill-active',reducing:'pill-active',completed:'pill-success',failed:'pill-danger',cancelled:'pill-waiting'};
|
||||
const fmtBytes=b=>{if(b==null||b<0)return '—';if(b<1024)return b+' B';if(b<1048576)return (b/1024).toFixed(1)+' KB';if(b<1073741824)return (b/1048576).toFixed(1)+' MB';return (b/1073741824).toFixed(2)+' GB'};
|
||||
const fmtTime=t=>t?new Date(t).toLocaleString():'—';
|
||||
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
let timer=null,current={page:'system',jobsStatus:'',jobsPage:1};
|
||||
const setPage=page=>{current.page=page;document.querySelectorAll('.nav-item').forEach(b=>b.classList.toggle('active',b.dataset.page===page));document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id==='page-'+page));const [t,s]=titles[page];document.getElementById('page-title').textContent=t;document.getElementById('page-sub').textContent=s;if(timer){clearInterval(timer);timer=null}if(page==='system'||page==='jobs'||page==='metrics'){refresh();timer=setInterval(refresh,5000)}};
|
||||
document.querySelectorAll('.nav-item').forEach(b=>b.addEventListener('click',()=>setPage(b.dataset.page)));
|
||||
|
||||
const refresh=()=>{if(document.hidden)return;const p=current.page;if(p==='system')loadSystem();else if(p==='jobs')loadJobs();else if(p==='metrics')loadMetrics()};
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});
|
||||
|
||||
async function loadSystem(){
|
||||
const r=await fetch('/ui/admin/api/system',{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
document.getElementById('k-version').textContent=v.version;
|
||||
document.getElementById('k-version-sub').textContent=v.node.db_engine+' · '+navigator.platform;
|
||||
const secs=v.uptime_seconds;
|
||||
const uptime=secs<3600?(secs/60).toFixed(0)+' min':secs<86400?(secs/3600).toFixed(1)+' h':(secs/86400).toFixed(1)+' d';
|
||||
document.getElementById('k-uptime').textContent=uptime;
|
||||
document.getElementById('k-started').textContent='since '+fmtTime(v.started_at);
|
||||
document.getElementById('k-active').textContent=v.active_jobs;
|
||||
document.getElementById('k-active-sub').textContent=v.running_jobs+' running · '+v.waiting_jobs+' waiting';
|
||||
document.getElementById('k-workers').textContent=v.workers_online;
|
||||
document.getElementById('k-workers-sub').textContent=(v.workers_total-v.workers_online)+' offline · '+v.workers_busy+' busy';
|
||||
const total=v.storage.datasets_bytes+v.storage.artifacts_bytes+v.storage.database_bytes;
|
||||
document.getElementById('storage-total').textContent=fmtBytes(total);
|
||||
const pct=b=>total?Math.round(b*100/total)+'%':'0%';
|
||||
document.getElementById('storage-bar').children[0].style.width=pct(v.storage.datasets_bytes);
|
||||
document.getElementById('storage-bar').children[1].style.width=pct(v.storage.artifacts_bytes);
|
||||
document.getElementById('storage-bar').children[2].style.width=pct(v.storage.database_bytes);
|
||||
document.getElementById('storage-datasets').textContent=fmtBytes(v.storage.datasets_bytes);
|
||||
document.getElementById('storage-artifacts').textContent=fmtBytes(v.storage.artifacts_bytes);
|
||||
document.getElementById('storage-db').textContent=fmtBytes(v.storage.database_bytes);
|
||||
document.getElementById('h-db').innerHTML=pill(v.health.database==='connected'?'Connected':'Error','pill-success',v.health.database==='connected'?'pill-danger':null);
|
||||
document.getElementById('h-users').innerHTML=pill(cap(v.health.userservice),'pill-success',null);
|
||||
document.getElementById('h-reducer').innerHTML=pill(cap(v.health.reducer),'pill-waiting',null);
|
||||
document.getElementById('health-state').textContent=v.health.database==='connected'?'all checks pass':'database unreachable';
|
||||
document.getElementById('n-binary').textContent=v.node.binary||'—';
|
||||
document.getElementById('n-addr').textContent=v.node.addr;
|
||||
document.getElementById('n-datadir').textContent=v.node.data_dir||'—';
|
||||
document.getElementById('n-engine').textContent=v.node.db_engine;
|
||||
document.getElementById('n-public').textContent=v.node.public_url||'—';
|
||||
document.getElementById('env-label').textContent=(v.node.public_url||'').replace(/^https?:\/\//,'')+' · '+v.node.db_engine+' · '+v.node.addr;
|
||||
}
|
||||
const cap=s=>s?s.charAt(0).toUpperCase()+s.slice(1):'—';
|
||||
const pill=(text,cls,fail)=>{const c=fail||cls;return '<span class="pill '+c+'"><i></i>'+esc(text)+'</span>'};
|
||||
|
||||
let jobFilter={};
|
||||
async function loadJobs(){
|
||||
const page=current.jobsPage,status=current.jobsStatus;
|
||||
const r=await fetch('/ui/admin/api/jobs?page='+page+'&per_page=10'+(status?'&status='+status:''),{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
const tabs=document.getElementById('job-tabs');
|
||||
const defs=[['', 'All'],['running','Running'],['pending','Waiting'],['completed','Completed'],['failed','Failed'],['cancelled','Cancelled']];
|
||||
tabs.replaceChildren();
|
||||
for(const [key,label] of defs){
|
||||
const n=v.counts[key]||0;
|
||||
const b=document.createElement('button');
|
||||
b.className='tab'+(key===status?' active':'');
|
||||
b.innerHTML=label+'<span class="n">'+n+'</span>';
|
||||
b.addEventListener('click',()=>{current.jobsStatus=key;current.jobsPage=1;loadJobs()});
|
||||
tabs.append(b);
|
||||
}
|
||||
const rows=document.getElementById('job-rows');
|
||||
rows.replaceChildren();
|
||||
if(!v.jobs.length){const tr=document.createElement('tr');tr.innerHTML='<td colspan="6"><div class="empty">No jobs'+(status?' with this status':'')+'.</div></td>';rows.append(tr)}
|
||||
for(const j of v.jobs){
|
||||
const tr=document.createElement('tr');
|
||||
const pct=j.total?Math.min(100,Math.round((j.completed+j.failed)*100/j.total)):0;
|
||||
tr.innerHTML='<td><div class="t-main">'+esc(j.workload)+'</div><div class="t-sub">'+esc(j.id.slice(0,8))+'…</div></td>'+
|
||||
'<td><code style="color:var(--text-2)">'+esc(j.workload)+'</code></td>'+
|
||||
'<td style="color:var(--text-2)">'+esc(j.owner)+'</td>'+
|
||||
'<td>'+pill(statusLabel[j.status]||j.status,statusClass[j.status]||'pill-waiting',null)+'</td>'+
|
||||
'<td><div class="bar"><span style="width:'+pct+'%"></span></div><div class="bar-label">'+j.completed+' / '+j.total+' shards'+(j.failed?' · '+j.failed+' failed':'')+'</div></td>'+
|
||||
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(j.created_at)+'</td>';
|
||||
rows.append(tr);
|
||||
}
|
||||
const from=(v.page-1)*v.per_page+1,to=Math.min(v.page*v.per_page,v.total);
|
||||
document.getElementById('job-range').textContent=v.total?(from+'–'+to+' of '+v.total+' jobs'):'no jobs';
|
||||
const prev=document.getElementById('pg-prev'),next=document.getElementById('pg-next');
|
||||
prev.disabled=v.page<=1;next.disabled=v.page*v.per_page>=v.total;
|
||||
prev.onclick=()=>{current.jobsPage--;loadJobs()};
|
||||
next.onclick=()=>{current.jobsPage++;loadJobs()};
|
||||
}
|
||||
|
||||
function dayChart(days){
|
||||
const max=Math.max(1,...days.map(d=>d.count));
|
||||
const w=460,h=150,bw=44,gap=18,base=118;
|
||||
let bars='';
|
||||
days.forEach((d,i)=>{const x=12+i*(bw+gap),bh=Math.round(d.count*base/max);bars+='<rect class="chart-bar'+(d.count===max&&d.count>0?' hot':'')+'" x="'+x+'" y="'+(base-bh+6)+'" width="'+bw+'" height="'+bh+'"/>';bars+='<text class="chart-label" x="'+x+'" y="146">'+d.day.slice(5)+'</text>'});
|
||||
return '<svg class="chart" viewBox="0 0 '+w+' '+h+'"><line class="chart-grid" x1="0" y1="30" x2="'+w+'" y2="30"/><line class="chart-grid" x1="0" y1="60" x2="'+w+'" y2="60"/><line class="chart-grid" x1="0" y1="90" x2="'+w+'" y2="90"/>'+bars+'</svg>';
|
||||
}
|
||||
async function loadMetrics(){
|
||||
const r=await fetch('/ui/admin/api/metrics',{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
document.getElementById('m-jobs7').textContent=v.jobs_last_7_days;
|
||||
document.getElementById('m-shards').textContent=v.shards_completed.toLocaleString();
|
||||
document.getElementById('m-shards-sub').textContent=(v.shards_completed+v.shards_failed)+' shards · '+v.shards_failed+' failed';
|
||||
document.getElementById('m-avg').textContent=v.avg_shard_seconds?v.avg_shard_seconds.toFixed(1)+'s':'—';
|
||||
document.getElementById('m-failrate').textContent=(v.failure_rate*100).toFixed(1)+'%';
|
||||
document.getElementById('m-failrate-sub').textContent=v.shards_failed+' of '+(v.shards_completed+v.shards_failed)+' shards failed';
|
||||
document.getElementById('chart-days').innerHTML=dayChart(v.jobs_by_day);
|
||||
const wl=document.getElementById('chart-workloads');
|
||||
wl.replaceChildren();
|
||||
if(!v.jobs_by_workload.length){const p=document.createElement('p');p.className='empty';p.textContent='No workloads used yet.';wl.append(p)}
|
||||
const max=Math.max(1,...v.jobs_by_workload.map(w=>w.count));
|
||||
for(const w of v.jobs_by_workload){
|
||||
const dt=document.createElement('dt');dt.textContent=w.workload;
|
||||
const dd=document.createElement('dd');
|
||||
dd.innerHTML='<div class="bar" style="width:100%"><span style="width:'+Math.round(w.count*100/max)+'%"></span></div>';
|
||||
wl.append(dt,dd);
|
||||
}
|
||||
}
|
||||
setPage('system');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -2,9 +2,11 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -112,3 +114,78 @@ func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
// handleUIAdminSystemJSON serves the admin "System" page: process info,
|
||||
// storage figures and health. Admin-only via the route chain.
|
||||
func (s *Server) handleUIAdminSystemJSON(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
view, err := s.uc.Admin.System(ctx)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// handleUIAdminJobsJSON serves one page of the admin jobs table. The owner
|
||||
// emails are resolved from the userservice when it is reachable; the resolver
|
||||
// failing is not fatal (cards fall back to short ids).
|
||||
func (s *Server) handleUIAdminJobsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
view, err := s.uc.Admin.Jobs(ctx, status, page, perPage, s.adminOwnerEmails(r))
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// handleUIAdminMetricsJSON serves the admin "Metrics" page.
|
||||
func (s *Server) handleUIAdminMetricsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
view, err := s.uc.Admin.Metrics(ctx)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// adminOwnerEmails resolves job owner ids to emails through the userservice,
|
||||
// which is the only place email addresses live. It never blocks the page on
|
||||
// failure: an empty map leaves the admin jobs table on short ids.
|
||||
func (s *Server) adminOwnerEmails(r *http.Request) map[uuid.UUID]string {
|
||||
if s.userserviceURL == "" {
|
||||
return nil
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/users", c.Value)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
var users []struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &users); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[uuid.UUID]string, len(users))
|
||||
for _, user := range users {
|
||||
id, err := uuid.Parse(user.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[id] = user.Email
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
node AdminNodeInfo
|
||||
ready func(context.Context) error
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAdmin(read AdminReadRepository, uiRead UIReadRepository, node AdminNodeInfo, ready func(context.Context) error, now func() time.Time) *Admin {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Admin{read: read, uiRead: uiRead, node: node, ready: ready, now: now}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
type fakeAdminRead struct {
|
||||
jobs []domain.Job
|
||||
taskCounts map[uuid.UUID]map[string]int
|
||||
sizes map[string]int64
|
||||
byDay map[string]int
|
||||
byWorkload map[string]int
|
||||
completed int64
|
||||
failed int64
|
||||
avg float64
|
||||
dbSize int64
|
||||
}
|
||||
|
||||
func (f *fakeAdminRead) ListJobsPaginated(ctx context.Context, status string, limit, offset int) ([]domain.Job, int, error) {
|
||||
var out []domain.Job
|
||||
for _, j := range f.jobs {
|
||||
if status == "" || string(j.Status) == status {
|
||||
out = append(out, j)
|
||||
}
|
||||
}
|
||||
total := len(out)
|
||||
if offset >= len(out) {
|
||||
return nil, total, nil
|
||||
}
|
||||
if offset+limit < len(out) {
|
||||
out = out[offset : offset+limit]
|
||||
} else {
|
||||
out = out[offset:]
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminRead) CountJobsByStatus(ctx context.Context) (map[string]int, error) {
|
||||
out := map[string]int{}
|
||||
for _, j := range f.jobs {
|
||||
out[string(j.Status)]++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminRead) TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) {
|
||||
return f.taskCounts, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminRead) JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) {
|
||||
return f.byDay, nil
|
||||
}
|
||||
func (f *fakeAdminRead) JobCountsByWorkload(ctx context.Context) (map[string]int, error) {
|
||||
return f.byWorkload, nil
|
||||
}
|
||||
func (f *fakeAdminRead) TaskStats(ctx context.Context) (int64, int64, float64, error) {
|
||||
return f.completed, f.failed, f.avg, nil
|
||||
}
|
||||
func (f *fakeAdminRead) ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) {
|
||||
return f.sizes, nil
|
||||
}
|
||||
func (f *fakeAdminRead) DatabaseSizeBytes(ctx context.Context) (int64, error) { return f.dbSize, nil }
|
||||
|
||||
type fakeUIRead struct {
|
||||
UIReadRepository // embedded: only ListWorkers is exercised
|
||||
workers []domain.Worker
|
||||
}
|
||||
|
||||
func (f *fakeUIRead) ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error) {
|
||||
return f.workers, nil
|
||||
}
|
||||
|
||||
func adminFixture() *Admin {
|
||||
return NewAdmin(
|
||||
&fakeAdminRead{},
|
||||
&fakeUIRead{},
|
||||
AdminNodeInfo{
|
||||
Version: "1.1.0-alpha.1", StartedAt: time.Unix(1_000_000, 0).UTC(),
|
||||
Binary: "/usr/local/bin/coordinator", Addr: ":8080", DataDir: "/var/lib/scimesh",
|
||||
DBEngine: "sqlite", PublicURL: "http://192.168.1.10:8080", Userservice: "http://127.0.0.1:41273",
|
||||
},
|
||||
func(context.Context) error { return nil },
|
||||
func() time.Time { return time.Unix(1_000_000+3600*3, 0).UTC() },
|
||||
)
|
||||
}
|
||||
|
||||
func TestAdminSystemAssemblesKpis(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
a := adminFixture()
|
||||
a.read = &fakeAdminRead{
|
||||
jobs: []domain.Job{
|
||||
{ID: uuid.New(), Status: domain.JobRunning, Workload: "similarity-search"},
|
||||
{ID: uuid.New(), Status: domain.JobPending, Workload: "similarity-search", OwnerID: &owner},
|
||||
{ID: uuid.New(), Status: domain.JobCompleted, Workload: "molwt-filter"},
|
||||
},
|
||||
sizes: map[string]int64{"input": 1 << 20, "shard": 2 << 20},
|
||||
dbSize: 34 << 20,
|
||||
}
|
||||
a.uiRead = &fakeUIRead{workers: []domain.Worker{
|
||||
{Status: domain.WorkerOnline},
|
||||
{Status: domain.WorkerBusy},
|
||||
{Status: domain.WorkerOffline},
|
||||
}}
|
||||
|
||||
v, err := a.System(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Version != "1.1.0-alpha.1" {
|
||||
t.Errorf("version = %q", v.Version)
|
||||
}
|
||||
if v.ActiveJobs != 2 || v.WaitingJobs != 1 || v.RunningJobs != 1 {
|
||||
t.Errorf("jobs: active=%d waiting=%d running=%d, want 2/1/1", v.ActiveJobs, v.WaitingJobs, v.RunningJobs)
|
||||
}
|
||||
if v.WorkersOnline != 2 || v.WorkersBusy != 1 || v.WorkersTotal != 3 {
|
||||
t.Errorf("workers: online=%d busy=%d total=%d, want 2/1/3", v.WorkersOnline, v.WorkersBusy, v.WorkersTotal)
|
||||
}
|
||||
if v.UptimeSeconds != 10800 {
|
||||
t.Errorf("uptime = %d, want 10800", v.UptimeSeconds)
|
||||
}
|
||||
if v.Storage.DatasetsBytes != 1<<20 || v.Storage.ArtifactsBytes != 2<<20 || v.Storage.DatabaseBytes != 34<<20 {
|
||||
t.Errorf("storage = %+v", v.Storage)
|
||||
}
|
||||
if v.Health.Database != "connected" || v.Health.Userservice != "embedded" || v.Health.Reducer != "idle" {
|
||||
t.Errorf("health = %+v", v.Health)
|
||||
}
|
||||
if v.Node.Binary != "/usr/local/bin/coordinator" || v.Node.DBEngine != "sqlite" {
|
||||
t.Errorf("node = %+v", v.Node)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSystemReportsUnhealthyDatabase(t *testing.T) {
|
||||
a := adminFixture()
|
||||
a.ready = func(context.Context) error { return errors.New("connection refused") }
|
||||
v, err := a.System(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Health.Database != "error" {
|
||||
t.Errorf("database health = %q, want error", v.Health.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsDerivesStatusAndResolvesOwners(t *testing.T) {
|
||||
jobID := uuid.New()
|
||||
owner := uuid.New()
|
||||
a := adminFixture()
|
||||
a.read = &fakeAdminRead{
|
||||
jobs: []domain.Job{{ID: jobID, Status: domain.JobPending, Workload: "similarity-graph", OwnerID: &owner, CreatedAt: time.Unix(100, 0)}},
|
||||
taskCounts: map[uuid.UUID]map[string]int{
|
||||
jobID: {"completed": 5, "failed": 1, "running": 2},
|
||||
},
|
||||
}
|
||||
|
||||
view, err := a.Jobs(context.Background(), "", 1, 20, map[uuid.UUID]string{owner: "alice@lab.org"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(view.Jobs) != 1 {
|
||||
t.Fatalf("jobs = %d, want 1", len(view.Jobs))
|
||||
}
|
||||
card := view.Jobs[0]
|
||||
if card.Owner != "alice@lab.org" || card.OwnerID != owner.String() {
|
||||
t.Errorf("owner = %q (%s)", card.Owner, card.OwnerID)
|
||||
}
|
||||
if card.Total != 8 || card.Completed != 5 || card.Failed != 1 {
|
||||
t.Errorf("progress: total=%d completed=%d failed=%d", card.Total, card.Completed, card.Failed)
|
||||
}
|
||||
if card.Status != "running" {
|
||||
t.Errorf("derived status = %q, want running (5 completed / 8 with 1 failed)", card.Status)
|
||||
}
|
||||
if view.Counts["pending"] != 1 {
|
||||
t.Errorf("counts = %v", view.Counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsFallsBackWithoutOwners(t *testing.T) {
|
||||
jobID := uuid.New()
|
||||
a := adminFixture()
|
||||
a.read = &fakeAdminRead{jobs: []domain.Job{{ID: jobID, Status: domain.JobPending, Workload: "x", CreatedAt: time.Unix(100, 0)}}}
|
||||
view, err := a.Jobs(context.Background(), "", 1, 20, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.Jobs[0].Owner != "cluster token" {
|
||||
t.Errorf("owner fallback = %q, want cluster token", view.Jobs[0].Owner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMetricsBuckets(t *testing.T) {
|
||||
now := time.Unix(1_000_000+3600*3, 0).UTC()
|
||||
since := now.Add(-6 * 24 * time.Hour).Truncate(24 * time.Hour)
|
||||
day := func(offset int) string { return since.Add(time.Duration(offset) * 24 * time.Hour).Format("2006-01-02") }
|
||||
a := adminFixture()
|
||||
a.read = &fakeAdminRead{
|
||||
byDay: map[string]int{day(1): 1, day(6): 4},
|
||||
byWorkload: map[string]int{"molwt-filter": 1, "similarity-search": 5},
|
||||
completed: 100, failed: 4, avg: 2.5,
|
||||
}
|
||||
v, err := a.Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.JobsByDay) != 7 || v.JobsLast7Days != 5 {
|
||||
t.Errorf("by day: %d entries, total %d (want 7 / 5)", len(v.JobsByDay), v.JobsLast7Days)
|
||||
}
|
||||
if v.JobsByDay[6].Count != 4 || v.JobsByDay[1].Count != 1 {
|
||||
t.Errorf("by day = %+v", v.JobsByDay)
|
||||
}
|
||||
if v.JobsByWorkload[0].Workload != "similarity-search" || v.JobsByWorkload[0].Count != 5 {
|
||||
t.Errorf("by workload = %+v", v.JobsByWorkload)
|
||||
}
|
||||
if v.FailureRate != 4.0/104.0 || v.AvgShardSeconds != 2.5 {
|
||||
t.Errorf("rate=%.4f avg=%.2f", v.FailureRate, v.AvgShardSeconds)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user