Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions completions/ccas.bash
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,12 @@ _ccas() {
*) COMPREPLY=(); return ;;
esac ;;
use-club) pos="slug" ;;
membership) opts="--server --trust-usernames --no-trust-usernames --club --all" ;;
history) opts="--server --full --include-finished --refresh --refresh-min-hours --club --all" ;;
recruit) opts="--server --alias --target --cumulative --source-clubs --time-limit-minutes --explore --no-explore --club --stdout --report" ;;
stats) opts="--server --since --until --club" ;;
membership) opts="--server --trust-usernames --no-trust-usernames --club --all --no-progress" ;;
history) opts="--server --full --include-finished --refresh --refresh-min-hours --club --all --no-progress" ;;
recruit) opts="--server --alias --target --cumulative --source-clubs --time-limit-minutes --explore --no-explore --club --stdout --report --no-progress" ;;
stats) opts="--server --since --until --club --no-progress" ;;
jobs) opts="--server --limit" ;;
logs) opts="--server"; pos="jobid" ;;
logs) opts="--server --no-progress"; pos="jobid" ;;
blacklist)
case "$sub" in
"") COMPREPLY=( $(compgen -W "add list remove --help" -- "$cur") ); return ;;
Expand Down
10 changes: 9 additions & 1 deletion src/main/scala/ccas/analysis/tables/AppSetting.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,16 @@ object AppSetting {
/** Retention window (days) for per-job log files in `${JOB_LOGS_DIR}`, applied by `JobRunner.live` on startup. */
val JobLogRetentionDays: Key[Int] = Key("job_log_retention_days", 14, _.toIntOption, _.toString)

/** Sample spacing (milliseconds) for progress-bar frames on `GET /api/jobs/{id}/progress`. The server samples the
* merged bar state at this interval and de-duplicates, so a busy job's updates collapse to at most one encoded +
* sent frame per interval (an idle job sends none). Caps server work + follower traffic while still delivering the
* latest state within one interval. Default 100ms (10 fps): smooth for a bar, light on the server; floored at 16ms.
* Read per subscribe in `JobRunner`.
*/
val ProgressRefreshIntervalMillis: Key[Int] = Key("progress_refresh_interval_ms", 100, _.toIntOption, _.toString)

/** Every known key — for discoverability (e.g. a future `ccas settings list`). */
val all: List[Key[?]] = List(CacheRetentionDays, JobLogRetentionDays)
val all: List[Key[?]] = List(CacheRetentionDays, JobLogRetentionDays, ProgressRefreshIntervalMillis)

def createTable: ZIO[PostgresClient, SQLException, Int] =
connectZIO {
Expand Down
12 changes: 12 additions & 0 deletions src/main/scala/ccas/cli/CcasApiClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import zio.json.*
import zio.stream.{ZPipeline, ZStream}

import ccas.server.jobs.JobLogStream
import ccas.utils.ProgressSnapshot
import ccas.utils.errors.ErrorResponse

/** Thin HTTP client to a local `CcasServer`. Every method fails with [[CliError]] (carrying an exit code) so the
Expand All @@ -22,6 +23,12 @@ trait CcasApiClient {
* follow a job's live log output; scoped internally so callers need no `Scope`.
*/
def streamLines(path: String)(onLine: String => UIO[Unit]): Task[Unit]

/** Stream a job's `/progress` NDJSON endpoint, invoking `onFrame` for each decoded [[ProgressSnapshot]]. A line that
* fails to decode is skipped (never fails the stream) — bar rendering is best-effort. Transport errors propagate as
* for [[streamLines]]; the follow path treats them as non-fatal (bars just stop).
*/
def streamProgress(path: String)(onFrame: ProgressSnapshot => UIO[Unit]): Task[Unit]
}

object CcasApiClient {
Expand Down Expand Up @@ -121,5 +128,10 @@ object CcasApiClient {
}
}
} yield ()

// Each `/progress` line is one `ProgressSnapshot` JSON object; a decode failure (should never happen) is dropped so
// a malformed frame can't kill live bar rendering. `streamLines` already filters the keepalive frames.
override def streamProgress(path: String)(onFrame: ProgressSnapshot => UIO[Unit]): Task[Unit] =
streamLines(path)(line => ZIO.fromEither(line.fromJson[ProgressSnapshot]).foldZIO(_ => ZIO.unit, onFrame))
}
}
59 changes: 39 additions & 20 deletions src/main/scala/ccas/cli/CliCommand.scala
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,22 @@ object CliCommand {

// Server commands — dispatched as HTTP calls by `Dispatcher`. Club-targeting fields hold the *parsed* request
// (`clubs`/`club` may be empty/None); `Dispatcher` resolves them against `--all` and the config's `current_club`.
final case class Membership(server: String, clubs: List[String], all: Boolean, trustUsernames: Option[Boolean])
extends ServerCommand
final case class Membership(
server: String,
clubs: List[String],
all: Boolean,
trustUsernames: Option[Boolean],
noProgress: Boolean
) extends ServerCommand
final case class History(
server: String,
clubs: List[String],
all: Boolean,
full: Boolean,
includeFinished: Boolean,
refresh: Boolean,
refreshMinHours: Option[Int]
refreshMinHours: Option[Int],
noProgress: Boolean
) extends ServerCommand
final case class Recruit(
server: String,
Expand All @@ -71,12 +77,18 @@ object CliCommand {
explore: Option[Boolean],
stdout: Boolean,
report: Boolean,
runId: Option[Int]
runId: Option[Int],
noProgress: Boolean
) extends ServerCommand
final case class Stats(
server: String,
club: Option[String],
since: Option[String],
until: Option[String],
noProgress: Boolean
) extends ServerCommand
final case class Stats(server: String, club: Option[String], since: Option[String], until: Option[String])
extends ServerCommand
final case class Jobs(server: String, limit: Option[Int]) extends ServerCommand
final case class Logs(server: String, jobId: String) extends ServerCommand
final case class Logs(server: String, jobId: String, noProgress: Boolean) extends ServerCommand
final case class BlacklistAdd(
server: String,
club: Option[String],
Expand Down Expand Up @@ -126,6 +138,11 @@ object CliCommand {
case _ => None
}

// Opt out of live progress bars while following a job (plain log lines only). Bars otherwise render on an interactive
// terminal; a non-TTY (piped/redirected) already suppresses them regardless of this flag.
private val noProgressOpt: Options[Boolean] =
Options.boolean("no-progress") ?? "Don't render progress bars while following the job (plain log lines only)"

private def intOpt(name: String): Options[Option[Int]] = Options.integer(name).map(_.toInt).optional

// Single-club target. Absent → Dispatcher falls back to the config's `current_club`.
Expand Down Expand Up @@ -172,10 +189,10 @@ object CliCommand {
.map(Use.apply)

private def membership(default: String): Command[CliCommand] =
Command("membership", serverOpt(default) ++ trustOpt ++ clubsOpt ++ allOpt)
Command("membership", serverOpt(default) ++ trustOpt ++ clubsOpt ++ allOpt ++ noProgressOpt)
.withHelp("Submit a membership-sync job (current club, --club a,b, or --all managed clubs)")
.map { case (server, trust, clubs, all) =>
Membership(server, clubs, all, trust)
.map { case (server, trust, clubs, all, noProgress) =>
Membership(server, clubs, all, trust, noProgress)
}

private def history(default: String): Command[CliCommand] =
Expand All @@ -186,10 +203,10 @@ object CliCommand {
(Options.boolean("include-finished") ?? "Re-queue recently finished matches for refresh") ++
(Options.boolean("refresh") ?? "Refresh already-stored matches, not just newly seen ones") ++
(intOpt("refresh-min-hours") ?? "Skip refreshing a match seen within the last N hours") ++
clubsOpt ++ allOpt
clubsOpt ++ allOpt ++ noProgressOpt
).withHelp("Submit a match-history crawl job (current club, --club a,b, or --all managed clubs)")
.map { case (server, full, includeFinished, refresh, refreshMinHours, clubs, all) =>
History(server, clubs, all, full, includeFinished, refresh, refreshMinHours)
.map { case (server, full, includeFinished, refresh, refreshMinHours, clubs, all, noProgress) =>
History(server, clubs, all, full, includeFinished, refresh, refreshMinHours, noProgress)
}

private val sourceClubsOpt: Options[List[String]] =
Expand All @@ -207,14 +224,16 @@ object CliCommand {
(intOpt("time-limit-minutes") ?? "Stop scouting after roughly N minutes") ++
exploreOpt ++ clubOpt ++
(Options.boolean("stdout") ?? "Print invited usernames (bare, newline-separated) to stdout for piping to a clipboard tool (e.g. wl-copy); logs go to stderr. Auto-confirms invites") ++
(Options.boolean("report") ?? "Show a past run's invited usernames instead of scouting (the club's latest run, or the run id given as an argument)"),
(Options.boolean("report") ?? "Show a past run's invited usernames instead of scouting (the club's latest run, or the run id given as an argument)") ++
noProgressOpt,
(Args.integer("run-id") ?? "With --report, the run id to show (default: the club's latest run)")
.atMost(1)
.map(_.headOption.map(_.toInt))
).withHelp("Submit a recruitment scouting job for a club (interactive runs confirm invites before marking them)")
// Options and the optional `[run-id]` arg combine as (optionsTuple, argValue), so destructure the two levels.
.map { case ((server, alias, target, cumulative, sourceClubs, timeLimitMinutes, explore, club, stdout, report), runId) =>
Recruit(server, club, alias, target, cumulative, sourceClubs, timeLimitMinutes, explore, stdout, report, runId)
.map {
case ((server, alias, target, cumulative, sourceClubs, timeLimitMinutes, explore, club, stdout, report, noProgress), runId) =>
Recruit(server, club, alias, target, cumulative, sourceClubs, timeLimitMinutes, explore, stdout, report, runId, noProgress)
}

private def stats(default: String): Command[CliCommand] =
Expand All @@ -223,19 +242,19 @@ object CliCommand {
serverOpt(default) ++
(Options.text("since").optional ?? "Start of the date window (ISO-8601 date or instant)") ++
(Options.text("until").optional ?? "End of the date window (requires --since)") ++
clubOpt
clubOpt ++ noProgressOpt
).withHelp("Submit a club performance-stats job")
.map { case (server, since, until, club) => Stats(server, club, since, until) }
.map { case (server, since, until, club, noProgress) => Stats(server, club, since, until, noProgress) }

private def jobs(default: String): Command[CliCommand] =
Command("jobs", serverOpt(default) ++ (intOpt("limit") ?? "Maximum number of recent jobs to list"))
.withHelp("List recent jobs and their status")
.map { case (server, limit) => Jobs(server, limit) }

private def logs(default: String): Command[CliCommand] =
Command("logs", serverOpt(default), Args.text("jobId") ?? "Job run id to poll")
Command("logs", serverOpt(default) ++ noProgressOpt, Args.text("jobId") ?? "Job run id to poll")
.withHelp("Poll a job's status and logs until it finishes")
.map { case (server, jobId) => Logs(server, jobId) }
.map { case ((server, noProgress), jobId) => Logs(server, jobId, noProgress) }

private def blacklistAdd(default: String): Command[CliCommand] =
Command(
Expand Down
72 changes: 72 additions & 0 deletions src/main/scala/ccas/cli/ClientProgressRenderer.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package ccas.cli

import zio.{Ref, UIO, ZIO}

import ccas.utils.{BarSnapshot, ProgressBar, ProgressDisplay, ProgressSnapshot}

/** Renders the bar frames streamed from `GET /api/jobs/{id}/progress` onto a local [[ProgressDisplay]] while the CLI
* follows a job. Each frame is a full latest-wins snapshot, so [[render]] reconciles it against the bars currently
* shown: update a bar we already track (skipping the redraw when its raw fields are unchanged), add one that's new,
* finish one that dropped out of the frame. The server sends raw `current` / `total` / `text`; [[ProgressBar.print]]
* re-renders the block bar + percentage at this terminal's width (the server can't, it doesn't know the width).
*
* `bars` maps a server-assigned bar id to the local [[ProgressBar]] standing in for it plus the last snapshot rendered
* for it — the two id spaces are independent, so this indirection is what lets successive frames address the same bar,
* and the stored snapshot lets an unchanged bar skip a redundant full-display redraw.
*/
final class ClientProgressRenderer private[cli] (
display: ProgressDisplay,
bars: Ref[Map[Int, ClientProgressRenderer.Tracked]]
) {
import ClientProgressRenderer.Tracked

/** Reconcile one snapshot frame onto the display. */
def render(frame: ProgressSnapshot): UIO[Unit] =
bars.get.flatMap { current =>
val incomingIds = frame.bars.map(_.id).toSet
val stale = current.keySet -- incomingIds
ZIO.foreachDiscard(frame.bars)(upsert(current, _)) *>
ZIO.foreachDiscard(stale)(drop(current, _))
}

// Update the local bar for this snapshot's id, or create one if it's new. A frame carries every live bar even when
// only one changed, so skip the print (and its whole-display redraw) when the raw fields are identical to last time.
private def upsert(current: Map[Int, Tracked], snap: BarSnapshot): UIO[Unit] =
current.get(snap.id) match {
case Some(t) if t.last == snap => ZIO.unit
case Some(t) =>
t.bar.print(snap.current, snap.total, snap.text) *> bars.update(_.updated(snap.id, Tracked(t.bar, snap)))
case None =>
for {
bar <- display.addBar
_ <- bars.update(_ + (snap.id -> Tracked(bar, snap)))
_ <- bar.print(snap.current, snap.total, snap.text)
} yield ()
}

// A bar absent from the latest frame is finished (erased) and forgotten.
private def drop(current: Map[Int, Tracked], id: Int): UIO[Unit] =
ZIO.foreachDiscard(current.get(id))(_.bar.finish) *> bars.update(_ - id)

/** Finish every remaining bar and forget them — called when the follow ends so the terminal is left clean (the
* `/progress` stream may close before a final empty frame arrives, leaving bars drawn). Idempotent.
*/
def clear: UIO[Unit] =
bars.getAndSet(Map.empty).flatMap(m => ZIO.foreachDiscard(m.values)(_.bar.finish))

/** Print a job-log line above the bars, interleaved cleanly via the display's render lock. */
def logLine(line: String): UIO[Unit] =
display.logLineAboveBars(line)
}

object ClientProgressRenderer {

/** A tracked bar: the local [[ProgressBar]] plus the last [[BarSnapshot]] rendered onto it (for change detection). */
final case class Tracked(bar: ProgressBar, last: BarSnapshot)

/** Build a renderer over a fresh [[ProgressDisplay]] drawing to `System.out`. Only ever built when bars are wanted
* (interactive TTY), so the display is always enabled.
*/
def make: UIO[ClientProgressRenderer] =
Ref.make(Map.empty[Int, Tracked]).map(new ClientProgressRenderer(ProgressDisplay.make(enabled = true), _))
}
15 changes: 10 additions & 5 deletions src/main/scala/ccas/cli/CompletionSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -50,28 +50,33 @@ object CompletionSpec {
Leaf(List("use-club"), Nil, Nil, Slug),
Leaf(
List("membership"),
List(server, "--trust-usernames", "--no-trust-usernames", clubFlag, "--all"),
List(server, "--trust-usernames", "--no-trust-usernames", clubFlag, "--all", "--no-progress"),
List(server, clubFlag),
NoArgs
),
Leaf(
List("history"),
List(server, "--full", "--include-finished", "--refresh", "--refresh-min-hours", clubFlag, "--all"),
List(server, "--full", "--include-finished", "--refresh", "--refresh-min-hours", clubFlag, "--all", "--no-progress"),
List(server, "--refresh-min-hours", clubFlag),
NoArgs
),
Leaf(
List("recruit"),
List(server, "--alias", "--target", "--cumulative", "--source-clubs", "--time-limit-minutes", "--explore",
"--no-explore", clubFlag, "--stdout", "--report"),
"--no-explore", clubFlag, "--stdout", "--report", "--no-progress"),
List(server, "--alias", "--target", "--source-clubs", "--time-limit-minutes", clubFlag),
// `--report`'s optional `[run-id]` is a bare numeric positional with nothing to complete, so NoArgs (suggest
// nothing) is the right shell behavior.
NoArgs
),
Leaf(List("stats"), List(server, "--since", "--until", clubFlag), List(server, "--since", "--until", clubFlag), NoArgs),
Leaf(
List("stats"),
List(server, "--since", "--until", clubFlag, "--no-progress"),
List(server, "--since", "--until", clubFlag),
NoArgs
),
Leaf(List("jobs"), List(server, "--limit"), List(server, "--limit"), NoArgs),
Leaf(List("logs"), List(server), List(server), JobId),
Leaf(List("logs"), List(server, "--no-progress"), List(server), JobId),
Leaf(
List("blacklist", "add"),
List(server, "--reason", "--months", clubFlag),
Expand Down
Loading