feat(cli): show download progress and copy setup links

This commit is contained in:
Lemon-miaow committed 2026-10-04 15:49:21 +08:00
1 parent e4a2a6339a
commit e67896979e
12 files changed
+229 -16

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ var bootstrapAssets embed.FS
//go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src plugins/limbo/gradle/verification-metadata.xml //go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src plugins/limbo/gradle/verification-metadata.xml
//go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src plugins/paper/gradle/verification-metadata.xml //go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src plugins/paper/gradle/verification-metadata.xml
//go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src plugins/velocity/gradle/verification-metadata.xml //go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src plugins/velocity/gradle/verification-metadata.xml
//go:embed plugins/shared/src //go:embed plugins/shared/src plugins/shared/build-progress.gradle
var gameStackAssets embed.FS var gameStackAssets embed.FS
// GameStackTar streams the embedded game-stack sources as a tar, rooted so that // GameStackTar streams the embedded game-stack sources as a tar, rooted so that
+3
View File
@@ -147,6 +147,9 @@ func TestBootstrapPinsViaBlockConnectionsOff(t *testing.T) {
// inputs from the script and from each Dockerfile's own COPY lines instead of restating // inputs from the script and from each Dockerfile's own COPY lines instead of restating
// them here; a fourth image inherits the check for free. // them here; a fourth image inherits the check for free.
func TestGameStackTarCarriesEveryBuildInput(t *testing.T) { func TestGameStackTarCarriesEveryBuildInput(t *testing.T) {
// The plugin builds invoke this shared init script after COPYing the directory;
// a directory entry alone would pass the COPY check even if the script was omitted.
requireEmbedded(t, "plugins/shared/build-progress.gradle")
// Matches the path only when GAME_STACK_DIR is followed by one, which skips the // Matches the path only when GAME_STACK_DIR is followed by one, which skips the
// build-context arguments (`"$GAME_STACK_DIR"`, `"${GAME_STACK_DIR}:/src:z"`) and // build-context arguments (`"$GAME_STACK_DIR"`, `"${GAME_STACK_DIR}:/src:z"`) and
// the glob for gradle's output, none of which are inputs this tar has to carry. // the glob for gradle's output, none of which are inputs this tar has to carry.
+66 -1
View File
@@ -2,10 +2,14 @@ package main
import ( import (
"context" "context"
"io"
"os"
"strings" "strings"
"time" "time"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
) )
// summaryModel is the terminal screen of the setup wizard. On a first run it // summaryModel is the terminal screen of the setup wizard. On a first run it
@@ -25,8 +29,13 @@ type summaryModel struct {
localHint bool // show the self-signed-cert note localHint bool // show the self-signed-cert note
// alerts is where the watchdog's alerts go; nil leaves the rows out. // alerts is where the watchdog's alerts go; nil leaves the rows out.
alerts *alertRoute alerts *alertRoute
copyText func(string) error
copyNotice string
copyFailed bool
} }
type summaryCopiedMsg struct{ err error }
func (m *summaryModel) Init() tea.Cmd { return nil } func (m *summaryModel) Init() tea.Cmd { return nil }
// arrowNavOK lets the root repurpose ←/→ to walk back through completed steps; // arrowNavOK lets the root repurpose ←/→ to walk back through completed steps;
@@ -34,9 +43,35 @@ func (m *summaryModel) Init() tea.Cmd { return nil }
func (m *summaryModel) arrowNavOK() bool { return true } func (m *summaryModel) arrowNavOK() bool { return true }
func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if copied, ok := msg.(summaryCopiedMsg); ok {
m.copyFailed = copied.err != nil
switch {
case copied.err != nil:
m.copyNotice = "Could not copy: " + copied.err.Error()
case os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "":
m.copyNotice = "Copy sent to terminal. If it does not paste, your terminal needs OSC 52 support."
default:
m.copyNotice = "Link copied to clipboard."
}
return m, nil
}
if key, ok := msg.(tea.KeyMsg); ok { if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() { switch key.String() {
case "c", "C": case "c", "C":
link := m.panelURL
if m.setupTokenURL != "" {
link = m.setupTokenURL
}
if link == "" {
m.copyNotice, m.copyFailed = "No link to copy.", true
return m, nil
}
copyText := m.copyText
if copyText == nil {
copyText = copyTerminalText
}
return m, func() tea.Msg { return summaryCopiedMsg{err: copyText(link)} }
case "n", "N":
return m, func() tea.Msg { return reconfigureConnectMsg{} } return m, func() tea.Msg { return reconfigureConnectMsg{} }
case "s", "S": case "s", "S":
return m, func() tea.Msg { return reconfigureStorageMsg{} } return m, func() tea.Msg { return reconfigureStorageMsg{} }
@@ -101,10 +136,40 @@ func (m *summaryModel) View() string {
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n") b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")
} }
b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit")) if m.copyNotice != "" {
style := tuiOK
if m.copyFailed {
style = tuiWarn
}
b.WriteString("\n" + style.Render(m.copyNotice) + "\n")
}
copyLabel := "copy panel link"
if m.setupTokenURL != "" {
copyLabel = "copy setup link"
}
b.WriteString("\n" + tuiAction("c", copyLabel, "n", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit"))
return b.String() return b.String()
} }
// SSH copies through the terminal; the remote host's desktop clipboard is unrelated.
// /dev/tty keeps the one-time link out of redirected stdout and install logs.
func copyTerminalText(text string) error {
if os.Getenv("SSH_TTY") == "" && os.Getenv("SSH_CONNECTION") == "" {
return clipboard.WriteAll(text)
}
tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
if err != nil {
return err
}
defer tty.Close()
sequence := ansi.SetSystemClipboard(text)
if os.Getenv("TMUX") != "" {
sequence = ansi.TmuxPassthrough(sequence)
}
_, err = io.WriteString(tty, sequence)
return err
}
// alertRoute is where this host's watchdog alerts go, as the summary shows it: // alertRoute is where this host's watchdog alerts go, as the summary shows it:
// by mail through the [smtp] relay to the Owners' verified addresses, and the // by mail through the [smtp] relay to the Owners' verified addresses, and the
// heartbeat that notices the host itself going down (docs/troubleshooting.md // heartbeat that notices the host itself going down (docs/troubleshooting.md
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestSummaryCopyLink(t *testing.T) {
t.Setenv("SSH_TTY", "")
t.Setenv("SSH_CONNECTION", "")
for _, tc := range []struct {
name, setup, want string
}{
{"first login", "https://panel.example/setup?token=test-token", "https://panel.example/setup?token=test-token"},
{"already set up", "", "https://panel.example"},
} {
t.Run(tc.name, func(t *testing.T) {
var copied string
m := &summaryModel{
panelURL: "https://panel.example", setupTokenURL: tc.setup,
copyText: func(text string) error { copied = text; return nil },
}
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")})
if cmd == nil {
t.Fatal("copy did not return a command")
}
msg := cmd()
if _, ok := msg.(summaryCopiedMsg); !ok {
t.Fatalf("c returned %T; must copy, not reconfigure", msg)
}
m.Update(msg)
if copied != tc.want || m.copyFailed || !strings.Contains(m.View(), "Link copied to clipboard.") {
t.Fatalf("copied %q, notice %q", copied, m.copyNotice)
}
_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
if _, ok := cmd().(reconfigureConnectMsg); !ok {
t.Fatal("n must still allow changing the connection")
}
})
}
}
func TestSummaryCopyFailureAndSSHFeedback(t *testing.T) {
t.Setenv("SSH_TTY", "/dev/pts/1")
m := &summaryModel{
panelURL: "https://panel.example",
copyText: func(string) error { return errors.New("clipboard unavailable") },
}
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("C")})
m.Update(cmd())
if !m.copyFailed || !strings.Contains(m.View(), "clipboard unavailable") {
t.Fatal("copy failure must be visible")
}
m.Update(summaryCopiedMsg{})
if m.copyFailed || !strings.Contains(m.View(), "Copy sent to terminal") || strings.Contains(m.View(), "Link copied to clipboard") {
t.Fatal("OSC 52 has no confirmation; do not claim the local clipboard was updated")
}
if _, cmd := (&summaryModel{}).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}); cmd != nil {
t.Fatal("a missing link must not trigger a clipboard write")
}
}
+6 -6
View File
@@ -3017,7 +3017,7 @@ EOF
chmod 0755 "${tmp}/felis" chmod 0755 "${tmp}/felis"
log "building ${FELIS_IMAGE} from the current felis binary" log "building ${FELIS_IMAGE} from the current felis binary"
docker build -t "$FELIS_IMAGE" "$tmp" docker build --progress=plain -t "$FELIS_IMAGE" "$tmp"
rm -rf "$tmp" rm -rf "$tmp"
} }
@@ -3031,7 +3031,7 @@ build_image_from_source() {
# Without the stamp main.version stays "dev", and `felis update` refuses to compare a # Without the stamp main.version stays "dev", and `felis update` refuses to compare a
# "dev" build against upstream rather than treating it as 0.0.0. So an unstamped image # "dev" build against upstream rather than treating it as 0.0.0. So an unstamped image
# is not a cosmetic problem: it silently disables update reporting for the install. # is not a cosmetic problem: it silently disables update reporting for the install.
docker build -t "$FELIS_IMAGE" \ docker build --progress=plain -t "$FELIS_IMAGE" \
--build-arg FELIS_VERSION="${FELIS_VERSION:-dev}" "$SRC_DIR" --build-arg FELIS_VERSION="${FELIS_VERSION:-dev}" "$SRC_DIR"
log "extracting the felis binary onto the host (${HOST_BIN})" log "extracting the felis binary onto the host (${HOST_BIN})"
@@ -3504,7 +3504,7 @@ build_game_image() {
limbo) limbo)
img="$FELIS_LIMBO_IMAGE" img="$FELIS_LIMBO_IMAGE"
log "building ${img} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})" log "building ${img} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})"
docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \ docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \
--build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \ --build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \
--build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \ --build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \
--build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \ --build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \
@@ -3515,7 +3515,7 @@ build_game_image() {
lobby) lobby)
img="$FELIS_LOBBY_IMAGE" img="$FELIS_LOBBY_IMAGE"
log "building ${img} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)" log "building ${img} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)"
docker build -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \ docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \
--build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
--build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \
@@ -3527,7 +3527,7 @@ build_game_image() {
# operator initContainer's job, so this image carries no /menu plugin and no secret gate. # operator initContainer's job, so this image carries no /menu plugin and no secret gate.
img="$FELIS_PAPER_IMAGE" img="$FELIS_PAPER_IMAGE"
log "building ${img} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)" log "building ${img} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)"
docker build -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \ docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \
--build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
-t "$img" "$GAME_STACK_DIR" -t "$img" "$GAME_STACK_DIR"
@@ -3633,7 +3633,7 @@ build_velocity_plugin() {
docker run --rm \ docker run --rm \
-v "${GAME_STACK_DIR}:/src:z" \ -v "${GAME_STACK_DIR}:/src:z" \
-w /src/plugins/velocity \ -w /src/plugins/velocity \
"$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build \ "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build --console=plain --init-script ../shared/build-progress.gradle \
|| die "felis-velocity plugin build failed" || die "felis-velocity plugin build failed"
local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar ) local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar )
[ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \ [ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \
+1 -1
View File
@@ -126,7 +126,7 @@ mkdir -p "${WORK}/velocity"
tar -C . --exclude=build --exclude=.gradle -cf - plugins/velocity plugins/shared | tar -C "${WORK}/velocity" -xf - tar -C . --exclude=build --exclude=.gradle -cf - plugins/velocity plugins/shared | tar -C "${WORK}/velocity" -xf -
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GRADLE_USER_HOME=/tmp/gradle \ docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GRADLE_USER_HOME=/tmp/gradle \
-v "${WORK}/velocity:/src" -w /src/plugins/velocity \ -v "${WORK}/velocity:/src" -w /src/plugins/velocity \
"$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build --console=plain --init-script ../shared/build-progress.gradle
jars=( "${WORK}"/velocity/plugins/velocity/build/libs/felis-velocity-*.jar ) jars=( "${WORK}"/velocity/plugins/velocity/build/libs/felis-velocity-*.jar )
[ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] || die "the felis-velocity build must produce exactly one plugin jar" [ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] || die "the felis-velocity build must produce exactly one plugin jar"
install -m 0644 "${jars[0]}" "${OUT}/felis-velocity.jar" install -m 0644 "${jars[0]}" "${OUT}/felis-velocity.jar"
+1 -1
View File
@@ -51,7 +51,7 @@ RUN if [ -z "${LIMBO_VERSION:-}" ]; then \
echo "LIMBO_VERSION is required (deploy/game-stack.lock)" >&2; exit 1; \ echo "LIMBO_VERSION is required (deploy/game-stack.lock)" >&2; exit 1; \
fi \ fi \
&& cd plugins/limbo \ && cd plugins/limbo \
&& gradle --no-daemon -PlimboVersion="$LIMBO_VERSION" build \ && gradle --no-daemon --console=plain --init-script ../shared/build-progress.gradle -PlimboVersion="$LIMBO_VERSION" build \
&& cp build/libs/*.jar /felis-limbo.jar && cp build/libs/*.jar /felis-limbo.jar
# ---- assemble the runtime ---- # ---- assemble the runtime ----
+1 -1
View File
@@ -40,7 +40,7 @@ WORKDIR /src
COPY plugins/paper/ ./plugins/paper/ COPY plugins/paper/ ./plugins/paper/
COPY plugins/shared/ ./plugins/shared/ COPY plugins/shared/ ./plugins/shared/
RUN cd plugins/paper \ RUN cd plugins/paper \
&& gradle --no-daemon build \ && gradle --no-daemon --console=plain --init-script ../shared/build-progress.gradle build \
&& cp build/libs/*.jar /felis-paper.jar && cp build/libs/*.jar /felis-paper.jar
# ---- assemble the runtime ---- # ---- assemble the runtime ----
+8 -1
View File
@@ -2320,6 +2320,13 @@ about … MiB under /var/lib/containerd, and … has … MiB free; nothing has b
once the asset downloads (the messages above it say which one failed), free that space, or once the asset downloads (the messages above it say which one failed), free that space, or
set `FELIS_PREFLIGHT=warn` to build anyway **[SH-TESTED]**. set `FELIS_PREFLIGHT=warn` to build anyway **[SH-TESTED]**.
Host builds keep Docker's output in the terminal scrollback. During plugin builds,
`[felis] Downloading <file> (<host>)` identifies each Gradle dependency download;
every ten seconds an active download reports bytes, speed and elapsed time. A
percentage appears only when the server supplies a total size. After thirty seconds
without new bytes the line says `no new data for …; waiting for the download`;
this reports a wait, without changing Gradle's timeout or retry behavior.
| Message | Meaning | What to do | | Message | Meaning | What to do |
|---|---|---| |---|---|---|
| `release vX publishes no SHA256SUMS … building them on this host instead` | the release predates release assets, or release.yml is still uploading them | nothing for an old release; for a new one, rerun once the release page lists `SHA256SUMS` | | `release vX publishes no SHA256SUMS … building them on this host instead` | the release predates release assets, or release.yml is still uploading them | nothing for an old release; for a new one, rerun once the release page lists `SHA256SUMS` |
@@ -2747,7 +2754,7 @@ host yourself, plus the off-site encryption key if the copy is in the bucket.
`cloudflared-felis` unit stay behind with the old disk, so the panel `cloudflared-felis` unit stay behind with the old disk, so the panel
hostnames answer Cloudflare error 1033 until a connector runs here. hostnames answer Cloudflare error 1033 until a connector runs here.
Run `sudo felis setup`, press `c` (change connection) on the status Run `sudo felis setup`, press `n` (change connection) on the status
screen and choose Cloudflare Tunnel + Access. On the step's first screen screen and choose Cloudflare Tunnel + Access. On the step's first screen
press `i` to install cloudflared, then `l` for `cloudflared tunnel login` press `i` to install cloudflared, then `l` for `cloudflared tunnel login`
(browser consent on your account, which writes `cert.pem`); `enter` opens (browser consent on your account, which writes `cert.pem`); `enter` opens
+2 -2
View File
@@ -4,10 +4,12 @@ go 1.26
require ( require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/descope/virtualwebauthn v1.0.5 github.com/descope/virtualwebauthn v1.0.5
github.com/go-logr/logr v1.4.2 github.com/go-logr/logr v1.4.2
github.com/go-webauthn/webauthn v0.17.4 github.com/go-webauthn/webauthn v0.17.4
@@ -27,13 +29,11 @@ require (
require ( require (
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
+74
View File
@@ -0,0 +1,74 @@
import org.gradle.api.logging.Logging
import org.gradle.internal.operations.BuildOperationListener
import org.gradle.internal.operations.BuildOperationListenerManager
import org.gradle.internal.operations.OperationProgressDetails
import org.gradle.internal.resource.ExternalResourceReadBuildOperationType
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
// The Docker images pin Gradle. Observe its download events without changing requests,
// dependency verification or caching; plain console output otherwise hides these events.
def logger = Logging.getLogger('felis.download')
def downloads = new ConcurrentHashMap()
def bytes = { long value ->
value >= 1024 * 1024 ? String.format(Locale.ROOT, '%.1f MiB', value / (1024.0 * 1024)) :
String.format(Locale.ROOT, '%.1f KiB', value / 1024.0)
}
def seconds = { long since -> TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - since) }
def listener = [
started: { operation, event ->
if (!(operation.details instanceof ExternalResourceReadBuildOperationType.Details)) return
def uri = URI.create(operation.details.location)
if (!(uri.scheme in ['http', 'https'])) return
def now = System.nanoTime()
def name = "${uri.path.tokenize('/').last()} (${uri.host})"
downloads[operation.id] = [name: name, bytes: 0L, total: 0L, started: now,
changed: now, sampled: now, sampledBytes: 0L]
logger.lifecycle("[felis] Downloading ${name}")
},
progress: { id, event ->
def download = downloads[id]
if (download == null || !(event.details instanceof OperationProgressDetails)) return
if (event.details.units != 'bytes') return
synchronized (download) {
if (event.details.progress > download.bytes) download.changed = System.nanoTime()
download.bytes = event.details.progress
download.total = event.details.total
}
},
finished: { operation, event ->
def download = downloads.remove(operation.id)
if (download == null) return
def outcome = event.failure != null ? 'Download failed' :
event.result?.missing ? 'Not found' : 'Downloaded'
logger.lifecycle("[felis] ${outcome} ${download.name} (${seconds(download.started)}s)")
}
] as BuildOperationListener
def manager = gradle.services.get(BuildOperationListenerManager)
manager.addListener(listener)
def timer = Executors.newSingleThreadScheduledExecutor({ task ->
def thread = new Thread(task, 'felis-download-progress')
thread.daemon = true
thread
} as java.util.concurrent.ThreadFactory)
timer.scheduleAtFixedRate({
downloads.values().each { download ->
synchronized (download) {
def now = System.nanoTime()
def rate = (long) ((download.bytes - download.sampledBytes) * 1e9 / (now - download.sampled))
def total = download.total > 0 ? bytes(download.total) : 'unknown size'
def percent = download.total > 0 ? " (${Math.min(100, (100L * download.bytes).intdiv(download.total))}%)" : ''
def idle = seconds(download.changed)
def waiting = idle >= 30 ? " | no new data for ${idle}s; waiting for the download" : ''
logger.lifecycle("[felis] ${download.name}: ${bytes(download.bytes)} / ${total}${percent} | ${bytes(rate)}/s | ${seconds(download.started)}s elapsed${waiting}")
download.sampled = now
download.sampledBytes = download.bytes
}
}
} as Runnable, 10, 10, TimeUnit.SECONDS)
gradle.buildFinished {
timer.shutdownNow()
manager.removeListener(listener)
}
+2 -2
View File
@@ -226,7 +226,7 @@ java -cp "$work/linkcard-classes:$adventure_api:$adventure_key:$examination_api"
for module in velocity paper; do for module in velocity paper; do
echo "==> plugins/$module: ./gradlew --no-daemon build" echo "==> plugins/$module: ./gradlew --no-daemon build"
( cd "plugins/$module" && ./gradlew --no-daemon build ) ( cd "plugins/$module" && ./gradlew --no-daemon --console=plain --init-script ../shared/build-progress.gradle build )
done done
echo "==> plugins/velocity: ./gradlew --no-daemon routingTest" echo "==> plugins/velocity: ./gradlew --no-daemon routingTest"
@@ -238,4 +238,4 @@ echo "==> plugins/paper: ./gradlew --no-daemon lobbyTest"
limbo_version="$(sed -n 's/^LIMBO_VERSION=//p' deploy/game-stack.lock)" limbo_version="$(sed -n 's/^LIMBO_VERSION=//p' deploy/game-stack.lock)"
[ -n "$limbo_version" ] || { echo "deploy/game-stack.lock sets no LIMBO_VERSION" >&2; exit 1; } [ -n "$limbo_version" ] || { echo "deploy/game-stack.lock sets no LIMBO_VERSION" >&2; exit 1; }
echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build loginTest" echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build loginTest"
( cd plugins/limbo && ./gradlew --no-daemon -PlimboVersion="$limbo_version" build loginTest ) ( cd plugins/limbo && ./gradlew --no-daemon --console=plain --init-script ../shared/build-progress.gradle -PlimboVersion="$limbo_version" build loginTest )