ui: per-host Jobs sub-tab; drop unused Settings stub

Adds /hosts/{id}/jobs page listing recent jobs for the host (newest
first, capped at 100) with click-through to /jobs/{id}. Converts the
Jobs placeholder <div> to a real <a> nav link; removes the Settings
stub entirely. Also registers durationHuman template func and a
.jobs-row CSS grid to match the existing .schd-row idiom.
This commit is contained in:
2026-05-07 22:49:10 +01:00
parent 6ef58a707e
commit 28c8b58f93
10 changed files with 411 additions and 3 deletions
+81
View File
@@ -288,6 +288,87 @@ func (s *Store) HasJobOfKind(ctx context.Context, hostID, kind string) (bool, er
return n > 0, nil
}
// ListJobsByHost returns recent jobs for hostID, ordered by
// created_at DESC, limited to at most `limit` rows. limit ≤ 0 is
// treated as no limit.
func (s *Store) ListJobsByHost(ctx context.Context, hostID string, limit int) ([]Job, error) {
q := `SELECT id, host_id, kind, status, scheduled_id, source_group_id,
actor_kind, actor_id, started_at, finished_at, exit_code,
stats, error, created_at
FROM jobs
WHERE host_id = ?
ORDER BY created_at DESC`
args := []any{hostID}
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("store: list jobs by host: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Job
for rows.Next() {
var (
j Job
schedID sql.NullString
groupID sql.NullString
actorID sql.NullString
startedAt sql.NullString
finishedAt sql.NullString
exitCode sql.NullInt64
stats sql.NullString
errMsg sql.NullString
createdAt string
)
if err := rows.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID, &groupID,
&j.ActorKind, &actorID, &startedAt, &finishedAt,
&exitCode, &stats, &errMsg, &createdAt); err != nil {
return nil, fmt.Errorf("store: scan job row: %w", err)
}
if schedID.Valid {
v := schedID.String
j.ScheduledID = &v
}
if groupID.Valid {
v := groupID.String
j.SourceGroupID = &v
}
if actorID.Valid {
v := actorID.String
j.ActorID = &v
}
if startedAt.Valid {
t, _ := time.Parse(time.RFC3339Nano, startedAt.String)
j.StartedAt = &t
}
if finishedAt.Valid {
t, _ := time.Parse(time.RFC3339Nano, finishedAt.String)
j.FinishedAt = &t
}
if exitCode.Valid {
i := int(exitCode.Int64)
j.ExitCode = &i
}
if stats.Valid && stats.String != "" {
j.Stats = json.RawMessage(stats.String)
}
if errMsg.Valid {
v := errMsg.String
j.Error = &v
}
t, _ := time.Parse(time.RFC3339Nano, createdAt)
j.CreatedAt = t
out = append(out, j)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate jobs by host: %w", err)
}
return out, nil
}
func nullableStr(s string) any {
if s == "" {
return nil
+83
View File
@@ -0,0 +1,83 @@
package store
import (
"context"
"testing"
"time"
)
func TestListJobsByHost_OrderingAndLimit(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
const hostID = "h-jobs-1"
seedHost(t, s, hostID)
// Create three jobs with explicit CreatedAt offsets.
base := time.Now().UTC().Truncate(time.Second)
for i, d := range []time.Duration{-3 * time.Hour, -1 * time.Hour, -2 * time.Hour} {
j := Job{
ID: "j-" + string(rune('a'+i)) + "0000000000000000000000000",
HostID: hostID,
Kind: "backup",
ActorKind: "user",
CreatedAt: base.Add(d),
}
// Truncate ID to 26 chars (ULID width); the test only needs it
// to be unique and stable across rows.
j.ID = j.ID[:26]
if err := s.CreateJob(ctx, j); err != nil {
t.Fatalf("create job %d: %v", i, err)
}
}
jobs, err := s.ListJobsByHost(ctx, hostID, 100)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(jobs) != 3 {
t.Fatalf("want 3 jobs, got %d", len(jobs))
}
// Newest first ordering by created_at DESC.
for i := 0; i < len(jobs)-1; i++ {
if !jobs[i].CreatedAt.After(jobs[i+1].CreatedAt) && !jobs[i].CreatedAt.Equal(jobs[i+1].CreatedAt) {
t.Fatalf("ordering broken at %d: %v then %v", i, jobs[i].CreatedAt, jobs[i+1].CreatedAt)
}
}
// Limit clamps results.
limited, err := s.ListJobsByHost(ctx, hostID, 2)
if err != nil {
t.Fatalf("list limit: %v", err)
}
if len(limited) != 2 {
t.Fatalf("limit 2: want 2 jobs, got %d", len(limited))
}
}
func TestListJobsByHost_OnlyThisHost(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
const a, b = "h-jobs-a", "h-jobs-b"
seedHost(t, s, a)
seedHost(t, s, b)
now := time.Now().UTC()
if err := s.CreateJob(ctx, Job{ID: "01HZZZZZZZZZZZZZZZZZZZZZ01", HostID: a, Kind: "backup", ActorKind: "user", CreatedAt: now}); err != nil {
t.Fatalf("create a: %v", err)
}
if err := s.CreateJob(ctx, Job{ID: "01HZZZZZZZZZZZZZZZZZZZZZ02", HostID: b, Kind: "backup", ActorKind: "user", CreatedAt: now}); err != nil {
t.Fatalf("create b: %v", err)
}
jobs, err := s.ListJobsByHost(ctx, a, 100)
if err != nil {
t.Fatalf("list a: %v", err)
}
if len(jobs) != 1 || jobs[0].HostID != a {
t.Fatalf("expected 1 job for host a, got %d (%v)", len(jobs), jobs)
}
}