changes
Some checks failed
Build and Deploy (internal) / Build Transmission Manager Image (push) Failing after 2m56s
Build and Deploy (internal) / Deploy Transmission Manager (internal) (push) Has been skipped

This commit is contained in:
2026-07-08 17:27:30 -03:00
parent 9981d0bc19
commit ec3e1ee6a3
7 changed files with 933 additions and 219 deletions

View File

@@ -0,0 +1,81 @@
name: Build and Deploy (internal)
on:
push:
branches:
- main
workflow_dispatch: {}
env:
REGISTRY_HOST: git.ivanch.me
REGISTRY_USERNAME: ivanch
IMAGE: ${{ env.REGISTRY_HOST }}/ivanch/transmission-manager
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
jobs:
build_transmission_manager:
name: Build Transmission Manager Image
runs-on: ubuntu-22.04
steps:
- name: Check out repository
uses: actions/checkout@v2
- name: Log in to Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" \
| docker login "${{ env.REGISTRY_HOST }}" \
-u "${{ env.REGISTRY_USERNAME }}" \
--password-stdin
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push Multi-Arch Image
uses: docker/build-push-action@v6
with:
push: true
context: .
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE }}:latest
deploy_transmission_manager:
name: Deploy Transmission Manager (internal)
runs-on: ubuntu-amd64
needs: build_transmission_manager
steps:
- name: Check KUBE_CONFIG validity
run: |
if [ -z "${KUBE_CONFIG}" ] || [ "${KUBE_CONFIG}" = "" ] || [ "${KUBE_CONFIG// }" = "" ]; then
echo "KUBE_CONFIG is not set or is empty."
exit 1
fi
- name: Check out repository
uses: actions/checkout@v2
- name: Download and install dependencies
run: |
apt-get update -y
apt-get install -y curl
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
install -m 0755 kubectl /usr/local/bin/kubectl
kubectl version --client
- name: Set up kubeconfig
run: |
cd deploy
echo "$KUBE_CONFIG" > kubeconfig.yaml
env:
KUBE_CONFIG: ${{ env.KUBE_CONFIG }}
- name: Check connection to cluster
run: |
cd deploy
kubectl --kubeconfig=kubeconfig.yaml cluster-info
- name: Rollout restart
run: |
cd deploy
kubectl --kubeconfig=kubeconfig.yaml rollout restart deployment transmission-manager -n media

141
AGENTS.md
View File

@@ -1,99 +1,58 @@
# Transmission Manager AGENTS.md # Transmission Manager - Agent Guide
## Project Overview ## First Rule
A lightweight single-binary Go web app that provides a beautiful dark-mode SPA for managing/viewing torrents in a Transmission RPC client. Always read `project-context.md` before making code or documentation changes. If a change alters project purpose, architecture, APIs, file roles, workflows, constraints, or implementation patterns, update `project-context.md` in the same change.
## Architecture ## Quick Summary
- **Backend**: Go (stdlib net/http only, no external dependencies). Serves on port 8080. Transmission Manager is a lightweight single-binary Go web app for managing and viewing torrents in a Transmission RPC client. It serves a dark-mode vanilla HTML/CSS/JS SPA and proxies Transmission RPC calls through a minimal Go backend.
- `GET /` → serves embedded static files (index.html, app.js, styles.css)
- `GET /api/torrents` → proxies to Transmission RPC, returns clean JSON
- `GET /api/stats` → global session stats
- **Frontend**: Vanilla HTML + CSS + JS. No frameworks. Single page. Dark mode only.
## Transmission RPC Details ## Stack
- RPC URL: `http://192.168.20.22:3210/transmission/rpc` - Backend: Go 1.24, standard library only.
- No auth required (open on LAN) - Frontend: Vanilla HTML, CSS, and JavaScript. No framework or build step.
- The RPC requires a session-ID handshake: - Static assets: embedded with Go `embed` from `web/`.
1. POST to the RPC URL - Runtime port: `8080`.
2. If response is 409, read the `X-Transmission-Session-Id` header from the response - Container: multi-stage Docker build from `golang:1.24-alpine` to `alpine:latest`.
3. Re-send the request with that header
4. Cache the session-ID for future requests
- For torrent list, request these fields:
```
id, name, totalSize, downloadedEver, uploadedEver, uploadRatio,
percentDone, status, eta, error, errorString, doneDate, isFinished,
rateDownload, rateUpload, peersConnected, peersGettingFromUs, peersSendingToUs,
peers (array of {address, clientName, progress, isDownloadingFrom, isUploadingTo, rateToClient, rateToPeer})
```
- "Peers with 100%" = count of peers in the `peers` array where `progress === 1.0`
- "Finished date" = `doneDate` (Unix timestamp, 0 means not finished)
## Design Requirements (CRITICAL) ## Current Files
- **Dark mode only** — deep dark background (#0a0a0f or similar) - `project-context.md` - canonical project context and implementation rules for agents.
- **Modern, stylish, clean** — think Linear, Vercel, Raycast aesthetic - `main.go` - HTTP server, embedded static file serving, environment-driven Transmission RPC client, connection warning page, API routes.
- **Accent color**: Use a vibrant accent (cyan/teal #00d4ff or purple #8b5cf6) - `web/index.html` - SPA shell and toolbar controls.
- **Typography**: System font stack, -apple-system, Inter-like - `web/app.js` - frontend state, polling, in-place rendering, sorting, pause/resume actions.
- **Torrent cards or rows** with: - `web/styles.css` - dark-only UI theme and responsive layout.
- Name (truncated with ellipsis if long) - `go.mod` - module `transmission-manager`, Go 1.24.
- Size (human-readable: GB, MB, etc.) - `Dockerfile` - production container build.
- Progress bar (animated, smooth)
- Percentage done
- Ratio (uploadRatio)
- Downloaded / Uploaded amounts
- Download/Upload speeds
- ETA (when downloading)
- Status badge (downloading, seeding, paused, finished, error)
- Finish date (formatted nicely, e.g. "Jul 8, 2026")
- Peer count with 100% (e.g. "3/7 peers at 100%")
- **Auto-refresh** every 3 seconds
- **Header** with app title and global stats (total download/upload speed, torrent count)
- **Responsive** — works on mobile and desktop
- Use CSS variables for theming
- Smooth transitions and micro-animations
- Cards with subtle borders and hover effects
## Code Structure ## Core API Surface
``` - `GET /` serves a Transmission connection warning page when setup fails; otherwise it serves the embedded frontend.
main.go — Go backend (HTTP server, Transmission RPC proxy, static file serving via embed) - `GET /api/torrents` returns Transmission torrent data.
web/ - `GET /api/stats` returns global session stats.
index.html — SPA shell - `POST /api/torrents/{id}/pause` maps to Transmission `torrent-stop`.
app.js — Frontend logic (fetch API, render, auto-refresh) - `POST /api/torrents/{id}/resume` maps to queue-respecting Transmission `torrent-start`.
styles.css — All styling
Dockerfile — Multi-stage: golang:1.24-alpine (build) → alpine:latest (runtime)
go.mod — module transmission-manager, go 1.24
```
## Go Implementation Notes ## Implementation Guidelines
- Use `embed` package to embed web/ directory into the binary - Keep the backend stdlib-only. Do not add Gin, Fiber, Chi, Gorilla, or other Go web frameworks.
- Transmission RPC communication via net/http client - Keep the frontend dependency-free. Do not add React, Vue, Svelte, bundlers, package managers, or transpilers.
- Implement session-ID handshake with retry on 409 - Preserve the single-binary model: static frontend files should remain embedded via `embed`.
- Use sync.Mutex to protect session-ID cache - Format display numbers in the frontend unless there is a clear backend reason.
- Format numbers nicely (human-readable sizes) in the frontend, not backend - Preserve in-place frontend updates during polling. Do not replace the whole torrent list on each refresh.
- CORS not needed (same origin) - Keep polling lightweight and avoid overlapping refresh requests.
- The Go server should be minimal and fast - Keep UI dark-only, modern, compact, responsive, and based on CSS variables.
## Dockerfile ## Transmission RPC Notes
```dockerfile - Default RPC URL: `http://192.168.20.22:3210/transmission/rpc`.
FROM golang:1.24-alpine AS builder - Override the RPC URL with `TRANSMISSION_URL`.
WORKDIR /app - Optional HTTP Basic Auth credentials come from `TRANSMISSION_RPC_USERNAME` and `TRANSMISSION_RPC_PASSWORD`; unset, empty, or literal `null` values mean no credential value.
COPY go.mod ./ - Handle the Transmission session-ID handshake:
RUN go mod download 1. POST to the RPC URL.
COPY . . 2. If the response is `409`, read `X-Transmission-Session-Id`.
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o transmission-manager . 3. Retry the same request with that header.
4. Cache the session ID behind a mutex.
- "Peers at 100%" means count `peers[]` entries where `progress === 1.0`.
- `doneDate` is a Unix timestamp; `0` means unfinished.
FROM alpine:latest ## Do Not
RUN apk add --no-cache ca-certificates - Do not add authentication unless explicitly requested.
WORKDIR /app - Do not add CI/CD or GitHub Actions.
COPY --from=builder /app/transmission-manager . - Do not create a GitHub repository.
EXPOSE 8080 - Do not vendor dependencies.
CMD ["./transmission-manager"] - Do not add broad tests or infrastructure for small changes; keep verification proportional.
```
## Do NOT
- Do not use any Go web framework (gin, fiber, etc.) — stdlib only
- Do not use any JS framework (React, Vue, etc.) — vanilla only
- Do not add authentication
- Do not add CI/CD or GitHub Actions
- Do not create any GitHub repository
- Do not add vendored dependencies
- Do not add tests unless necessary — keep it simple

342
main.go
View File

@@ -6,10 +6,15 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"io" "io"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"net/url"
"os"
"strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -18,31 +23,96 @@ import (
var webFS embed.FS var webFS embed.FS
const ( const (
rpcURL = "http://192.168.20.22:3210/transmission/rpc" defaultRPCURL = "http://192.168.20.22:3210/transmission/rpc"
sessionHeader = "X-Transmission-Session-Id" envTransmissionURL = "TRANSMISSION_URL"
envTransmissionRPCUsername = "TRANSMISSION_RPC_USERNAME"
envTransmissionRPCPassword = "TRANSMISSION_RPC_PASSWORD"
sessionHeader = "X-Transmission-Session-Id"
) )
// Client is a minimal Transmission RPC client with session-id handshake. // Client is a minimal Transmission RPC client with session-id handshake.
type Client struct { type Client struct {
http *http.Client http *http.Client
rpcURL string
username string
password string
mu sync.Mutex mu sync.Mutex
sessionID string sessionID string
} }
func NewClient() *Client { func NewClient(rpcURL, username, password string) *Client {
return &Client{ return &Client{
http: &http.Client{Timeout: 15 * time.Second}, http: &http.Client{Timeout: 15 * time.Second},
rpcURL: rpcURL,
username: username,
password: password,
} }
} }
func NewClientFromEnv() (*Client, error) {
rpcURL := configuredRPCURL()
if err := validateRPCURL(rpcURL); err != nil {
return nil, err
}
return NewClient(
rpcURL,
optionalEnv(envTransmissionRPCUsername),
optionalEnv(envTransmissionRPCPassword),
), nil
}
func configuredRPCURL() string {
rpcURL := strings.TrimSpace(os.Getenv(envTransmissionURL))
if rpcURL == "" {
return defaultRPCURL
}
return rpcURL
}
func optionalEnv(name string) string {
value := os.Getenv(name)
if strings.EqualFold(strings.TrimSpace(value), "null") {
return ""
}
return value
}
func validateRPCURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("%s is invalid: %w", envTransmissionURL, err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("%s must be an absolute http or https URL", envTransmissionURL)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("%s must use http or https", envTransmissionURL)
}
return nil
}
func redactedURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
if parsed.User != nil {
parsed.User = url.User("redacted")
}
return parsed.String()
}
// rpc sends a JSON-RPC request, handling the 409 session handshake with one retry. // rpc sends a JSON-RPC request, handling the 409 session handshake with one retry.
func (c *Client) rpc(ctx context.Context, body []byte) ([]byte, error) { func (c *Client) rpc(ctx context.Context, body []byte) ([]byte, error) {
doRequest := func(sessionID string) (*http.Response, error) { doRequest := func(sessionID string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if c.username != "" || c.password != "" {
req.SetBasicAuth(c.username, c.password)
}
if sessionID != "" { if sessionID != "" {
req.Header.Set(sessionHeader, sessionID) req.Header.Set(sessionHeader, sessionID)
} }
@@ -103,6 +173,7 @@ type rpcRequest struct {
type rpcArgs struct { type rpcArgs struct {
Fields []string `json:"fields,omitempty"` Fields []string `json:"fields,omitempty"`
IDs []int `json:"ids,omitempty"`
} }
type rpcResponse struct { type rpcResponse struct {
@@ -147,6 +218,30 @@ func (c *Client) Stats(ctx context.Context) (json.RawMessage, error) {
return resp.Args, nil return resp.Args, nil
} }
func (c *Client) TorrentAction(ctx context.Context, method string, ids []int) error {
reqBody, _ := json.Marshal(rpcRequest{
Method: method,
Arguments: rpcArgs{IDs: ids},
})
data, err := c.rpc(ctx, reqBody)
if err != nil {
return err
}
var resp rpcResponse
if err := json.Unmarshal(data, &resp); err != nil {
return err
}
if resp.Result != "success" {
return fmt.Errorf("rpc result: %s", resp.Result)
}
return nil
}
func (c *Client) Check(ctx context.Context) error {
_, err := c.Stats(ctx)
return err
}
func writeJSON(w http.ResponseWriter, data json.RawMessage) { func writeJSON(w http.ResponseWriter, data json.RawMessage) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(append(data, '\n')) _, _ = w.Write(append(data, '\n'))
@@ -158,13 +253,242 @@ func writeError(w http.ResponseWriter, status int, msg string) {
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
} }
func writeConnectionWarningPage(w http.ResponseWriter, target string, err error) {
if target == "" {
target = "not configured"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintf(w, `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Transmission connection warning</title>
<style>
:root {
--bg: #0a0a0f;
--panel: #12121c;
--panel-2: #181826;
--border: rgba(255, 255, 255, 0.1);
--text: #f5f6fa;
--muted: #8a8d9b;
--accent: #00d4ff;
--warn: #fbbf24;
--err: #f87171;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
background: radial-gradient(900px 480px at 82%% -8%%, rgba(139, 92, 246, 0.07), transparent 70%%),
radial-gradient(820px 460px at 12%% -4%%, rgba(0, 212, 255, 0.06), transparent 70%%), var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
main {
width: min(720px, 100%%);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px;
box-shadow: 0 18px 55px rgba(0, 0, 0, 0.35);
}
.eyebrow {
color: var(--warn);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
margin: 0 0 10px;
}
h1 {
margin: 0 0 10px;
color: var(--text);
font-size: clamp(26px, 5vw, 40px);
line-height: 1.05;
}
p {
color: var(--muted);
line-height: 1.55;
margin: 0 0 18px;
}
dl {
display: grid;
gap: 10px;
margin: 22px 0;
}
.row {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
}
dt {
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
margin-bottom: 6px;
text-transform: uppercase;
}
dd {
margin: 0;
overflow-wrap: anywhere;
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
font-size: 13px;
}
.error {
color: #fca5a5;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 20px;
}
a {
color: var(--bg);
background: var(--accent);
border-radius: 999px;
display: inline-flex;
padding: 10px 16px;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
text-decoration: none;
text-transform: uppercase;
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 0;
}
</style>
</head>
<body>
<main>
<p class="eyebrow">Connection warning</p>
<h1>Transmission is not reachable</h1>
<p>The manager is running, but it could not set up a connection to the Transmission RPC endpoint.</p>
<dl>
<div class="row">
<dt>RPC target</dt>
<dd>%s</dd>
</div>
<div class="row">
<dt>Error</dt>
<dd class="error">%s</dd>
</div>
</dl>
<p>Check %s, %s, and %s, then refresh this page.</p>
<div class="actions">
<a href="/">Retry connection</a>
<p class="hint">The app will load once the RPC endpoint responds successfully.</p>
</div>
</main>
</body>
</html>`, html.EscapeString(target), html.EscapeString(err.Error()), envTransmissionURL, envTransmissionRPCUsername, envTransmissionRPCPassword)
}
func requireTransmissionClient(w http.ResponseWriter, client *Client, setupErr error) bool {
if setupErr == nil && client != nil {
return true
}
msg := "transmission client is not configured"
if setupErr != nil {
msg = setupErr.Error()
}
writeError(w, http.StatusBadGateway, msg)
return false
}
func handleTorrentAction(client *Client, setupErr error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if !requireTransmissionClient(w, client, setupErr) {
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/torrents/")
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
writeError(w, http.StatusNotFound, "not found")
return
}
id, err := strconv.Atoi(parts[0])
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid torrent id")
return
}
var method string
switch parts[1] {
case "pause":
method = "torrent-stop"
case "resume":
method = "torrent-start"
default:
writeError(w, http.StatusNotFound, "not found")
return
}
if err := client.TorrentAction(r.Context(), method, []int{id}); err != nil {
log.Printf("torrent action %s id=%d: %v", parts[1], id, err)
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, json.RawMessage(`{"ok":true}`))
}
}
func handleStaticFiles(fileServer http.Handler, client *Client, setupErr error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && (r.URL.Path == "/" || r.URL.Path == "/index.html") {
target := redactedURL(configuredRPCURL())
if setupErr != nil {
writeConnectionWarningPage(w, target, setupErr)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
err := client.Check(ctx)
cancel()
if err != nil {
log.Printf("connection check: %v", err)
writeConnectionWarningPage(w, target, err)
return
}
}
fileServer.ServeHTTP(w, r)
}
}
func main() { func main() {
client := NewClient() client, setupErr := NewClientFromEnv()
if setupErr != nil {
log.Printf("transmission client setup: %v", setupErr)
} else {
log.Printf("transmission RPC target: %s", redactedURL(client.rpcURL))
}
mux := http.NewServeMux() mux := http.NewServeMux()
// API routes // API routes
mux.HandleFunc("/api/torrents", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/torrents", func(w http.ResponseWriter, r *http.Request) {
if !requireTransmissionClient(w, client, setupErr) {
return
}
args, err := client.Torrents(r.Context()) args, err := client.Torrents(r.Context())
if err != nil { if err != nil {
log.Printf("torrents: %v", err) log.Printf("torrents: %v", err)
@@ -173,8 +497,12 @@ func main() {
} }
writeJSON(w, args) writeJSON(w, args)
}) })
mux.HandleFunc("/api/torrents/", handleTorrentAction(client, setupErr))
mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
if !requireTransmissionClient(w, client, setupErr) {
return
}
args, err := client.Stats(r.Context()) args, err := client.Stats(r.Context())
if err != nil { if err != nil {
log.Printf("stats: %v", err) log.Printf("stats: %v", err)
@@ -190,7 +518,7 @@ func main() {
log.Fatalf("embed sub: %v", err) log.Fatalf("embed sub: %v", err)
} }
fileServer := http.FileServer(http.FS(webRoot)) fileServer := http.FileServer(http.FS(webRoot))
mux.Handle("/", fileServer) mux.Handle("/", handleStaticFiles(fileServer, client, setupErr))
addr := ":8080" addr := ":8080"
log.Printf("transmission-manager listening on %s", addr) log.Printf("transmission-manager listening on %s", addr)

123
project-context.md Normal file
View File

@@ -0,0 +1,123 @@
# Project Context
This file is the canonical project context for AI agents working on Transmission Manager. Read it before making changes, and keep it updated whenever project purpose, architecture, APIs, file roles, workflows, constraints, or implementation patterns change.
## Purpose
Transmission Manager is a lightweight local/LAN web app for viewing and managing torrents from a Transmission RPC daemon. The product goal is a fast, clean, dark-mode SPA that feels modern while remaining simple to build, deploy, and inspect.
The app is intentionally small: one Go binary serves both the API and embedded frontend assets. The frontend is vanilla JavaScript, HTML, and CSS, with no package manager, no compile step, and no JS framework.
## Technology Stack
- Go module: `transmission-manager`
- Go version: `1.24`
- Backend dependencies: standard library only
- Frontend dependencies: none
- Static assets: embedded with Go `embed`
- Runtime port: `8080`
- Docker build: `golang:1.24-alpine` builder, `alpine:latest` runtime
- Default Transmission RPC target: `http://192.168.20.22:3210/transmission/rpc`
- Runtime RPC configuration: `TRANSMISSION_URL`, `TRANSMISSION_RPC_USERNAME`, and `TRANSMISSION_RPC_PASSWORD`
## Repository Organization
- `main.go`: backend entrypoint. Owns the HTTP server, embedded static file serving, environment-driven Transmission RPC client configuration, session-ID handshake, connection warning page, JSON helpers, and API routes.
- `web/index.html`: SPA shell. Defines the header, stats pills, filter input, sort buttons, list container, empty state, error state, footer, and script/style links.
- `web/app.js`: frontend application logic. Owns state, polling, fetch helpers, formatting, torrent classification, sorting, keyed DOM reconciliation, pause/resume actions, and event wiring.
- `web/styles.css`: all styling. Owns dark theme variables, layout, cards, badges, progress bars, action buttons, empty/error states, and responsive rules.
- `go.mod`: Go module declaration.
- `Dockerfile`: multi-stage production build.
- `AGENTS.md`: quick-start guide for coding agents. It must point agents back to this file.
## Backend Architecture
The backend uses `net/http` with a single `http.ServeMux`. It exposes:
- `GET /api/torrents`: sends Transmission `torrent-get` with the required torrent fields and returns the RPC `arguments` JSON directly.
- `GET /api/stats`: sends Transmission `session-stats` and returns the RPC `arguments` JSON directly.
- `POST /api/torrents/{id}/pause`: validates a positive numeric torrent id and sends `torrent-stop`.
- `POST /api/torrents/{id}/resume`: validates a positive numeric torrent id and sends queue-respecting `torrent-start`.
- `/`: checks the Transmission connection and serves a warning page with the connection error when setup fails; otherwise serves embedded files from `web/`.
Transmission RPC calls must reuse the existing `Client.rpc` path so session-ID handling is consistent. The session ID is cached in `Client.sessionID` and guarded by `sync.Mutex`.
API errors return JSON with an `error` string. Invalid torrent action methods should return `405`; invalid ids should return `400`; unknown action paths should return `404`; Transmission failures should return `502`.
## Transmission RPC Model
The RPC endpoint defaults to `http://192.168.20.22:3210/transmission/rpc` and can be overridden with `TRANSMISSION_URL`. `TRANSMISSION_RPC_USERNAME` and `TRANSMISSION_RPC_PASSWORD` are optional; unset, empty, or literal `null` values mean no credential value. When either credential has a value, the backend sends HTTP Basic Auth on RPC requests.
Transmission usually rejects the first request or an expired session with HTTP `409` and a fresh `X-Transmission-Session-Id` header. Correct behavior is to drain and close the response body, cache the new session ID, and retry the same JSON request once.
Torrent list requests should include:
```text
id, name, totalSize, downloadedEver, uploadedEver, uploadRatio,
percentDone, status, eta, error, errorString, doneDate, isFinished,
rateDownload, rateUpload, peersConnected, peersGettingFromUs, peersSendingToUs,
peers
```
Peer objects are expected to include `address`, `clientName`, `progress`, `isDownloadingFrom`, `isUploadingTo`, `rateToClient`, and `rateToPeer` when available.
The UI definition of "peers at 100%" is an absolute count of `peers[]` entries whose `progress === 1.0`. Do not reinterpret it as a percentage of connected peers.
## Frontend Architecture
The frontend is a single page with module-level state in `web/app.js`. It polls every 3 seconds and fetches torrents and stats in parallel.
Important patterns:
- Polling must not create an F5-style visual refresh. Use keyed DOM reconciliation by torrent id.
- Existing torrent cards should be updated in place. Create DOM only for new torrents, move existing nodes when sort order changes, and remove nodes only for hidden or removed torrents.
- Keep `syncInFlight` / queued-sync behavior or equivalent protection so requests do not overlap.
- Preserve filter input focus and local UI state during polling.
- Action buttons are delegated from the torrent list. Pause/resume requests show pending text, disable the clicked button, then trigger an immediate sync on success.
- Action failures should be shown inline on the card without clearing the list.
Current sort keys:
- `status`: default. Orders by status rank, then progress, then name.
- `name`
- `progress`
- `ratio`
- `size`
- `peer100`: orders by peers at 100% descending, then connected peers descending, progress descending, and name ascending.
## UI And Design Rules
- Dark mode only.
- Use CSS variables from `:root` for theme values.
- Keep the aesthetic modern, compact, and clean: dark background, subtle borders, cyan/purple accent, small badges, smooth progress bars.
- Cards should have subtle hover effects and stable layout.
- Card entrance animation should apply only to newly inserted cards, not every poll.
- The app must remain usable on mobile and desktop.
- Long torrent names must truncate with ellipsis rather than breaking layout.
## Implementation Rules
- Do not add external Go dependencies.
- Do not add frontend dependencies, bundlers, or transpilation.
- Do not add auth unless explicitly requested.
- Do not add CI/CD, GitHub Actions, or repository setup.
- Keep changes scoped to the app files unless the user asks for broader project work.
- Prefer small helper functions over new abstractions unless duplication or complexity justifies the abstraction.
- Format Go with `gofmt` when the tool is available.
- Use frontend formatting helpers for human-readable sizes, speeds, ratios, dates, percentages, and ETA.
- Keep comments sparse and useful; avoid narrating obvious code.
- Use ASCII in new documentation unless there is a specific reason to use non-ASCII characters.
## Verification
Preferred checks:
```powershell
gofmt -w main.go
go test ./...
node --check web\app.js
git diff --check
docker build .
```
Known local environment note from July 8, 2026: `go` and `gofmt` were not available in PATH, and Docker was configured to a remote builder with an SSH host-key mismatch. If that remains true, report the limitation instead of changing SSH trust settings automatically.

View File

@@ -1,8 +1,8 @@
"use strict"; "use strict";
/* ============================================================ /* ============================================================
Transmission Manager frontend Transmission Manager - frontend
Vanilla SPA. Auto-refreshes every 3s. Vanilla SPA. Auto-refreshes every 3s with in-place updates.
============================================================ */ ============================================================ */
const REFRESH_MS = 3000; const REFRESH_MS = 3000;
@@ -24,18 +24,34 @@ const state = {
lastError: null, lastError: null,
}; };
const torrentCards = new Map();
const actionStates = new Map();
let syncInFlight = false;
let syncQueued = false;
const $ = (sel) => document.querySelector(sel); const $ = (sel) => document.querySelector(sel);
/* ---------- Fetching ---------- */ /* ---------- Fetching ---------- */
async function fetchJSON(url) { async function fetchJSON(url, options = {}) {
const res = await fetch(url, { cache: "no-store" }); const res = await fetch(url, { cache: "no-store", ...options });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); const text = await res.text();
return res.json(); let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const message = data && data.error ? data.error : `HTTP ${res.status} for ${url}`;
throw new Error(message);
}
return data;
} }
async function loadTorrents() { async function loadTorrents() {
const data = await fetchJSON("/api/torrents"); const data = await fetchJSON("/api/torrents");
// Transmission returns { torrents: [...] }
state.torrents = (data && data.torrents) || []; state.torrents = (data && data.torrents) || [];
} }
@@ -45,17 +61,29 @@ async function loadStats() {
} }
async function sync() { async function sync() {
if (syncInFlight) {
syncQueued = true;
return;
}
syncInFlight = true;
setSyncing(true); setSyncing(true);
try { try {
await Promise.all([loadTorrents(), loadStats()]); await Promise.all([loadTorrents(), loadStats()]);
state.lastError = null; state.lastError = null;
renderAll();
} catch (err) { } catch (err) {
state.lastError = err.message || String(err); state.lastError = err.message || String(err);
renderError();
} finally { } finally {
renderAll();
setSyncing(false); setSyncing(false);
$("#last-updated").textContent = `updated ${formatTime(new Date())}`; const updated = $("#last-updated");
if (updated) updated.textContent = `updated ${formatTime(new Date())}`;
syncInFlight = false;
}
if (syncQueued) {
syncQueued = false;
sync();
} }
} }
@@ -67,6 +95,25 @@ function setSyncing(on) {
if (t) t.textContent = on ? "syncing" : "live"; if (t) t.textContent = on ? "syncing" : "live";
} }
async function runTorrentAction(id, action) {
const pendingText = action === "pause" ? "Pausing..." : "Resuming...";
actionStates.set(id, { pending: action, error: null, pendingText });
updateCardByID(id);
try {
await fetchJSON(`/api/torrents/${id}/${action}`, { method: "POST" });
actionStates.delete(id);
await sync();
} catch (err) {
actionStates.set(id, {
pending: null,
error: err.message || String(err),
pendingText: "",
});
updateCardByID(id);
}
}
/* ---------- Formatting helpers ---------- */ /* ---------- Formatting helpers ---------- */
function humanSize(bytes) { function humanSize(bytes) {
if (bytes == null || isNaN(bytes) || bytes < 0) return "—"; if (bytes == null || isNaN(bytes) || bytes < 0) return "—";
@@ -155,24 +202,50 @@ function classify(t) {
} }
} }
function actionForStatus(cls) {
const pausable = ["checking", "downloading", "queued", "seeding"].includes(cls.key);
if (pausable) {
return { action: "pause", label: "Pause", pendingText: "Pausing..." };
}
return { action: "resume", label: "Resume", pendingText: "Resuming..." };
}
/* ---------- Sorting ---------- */ /* ---------- Sorting ---------- */
const STATUS_RANK = { error: 0, downloading: 1, seeding: 2, queued: 3, checking: 4, finished: 5, paused: 6, idle: 7 }; const STATUS_RANK = { error: 0, downloading: 1, seeding: 2, queued: 3, checking: 4, finished: 5, paused: 6, idle: 7 };
function compareName(a, b) {
return (a.name || "").localeCompare(b.name || "");
}
function sortTorrents(list) { function sortTorrents(list) {
const key = state.sortKey; const key = state.sortKey;
const sorted = list.slice(); const sorted = list.slice();
sorted.sort((a, b) => { sorted.sort((a, b) => {
switch (key) { switch (key) {
case "name": return a.name.localeCompare(b.name); case "name":
case "progress": return (b.percentDone || 0) - (a.percentDone || 0); return compareName(a, b);
case "ratio": return (b.uploadRatio || 0) - (a.uploadRatio || 0); case "progress":
case "size": return (b.totalSize || 0) - (a.totalSize || 0); return (b.percentDone || 0) - (a.percentDone || 0) || compareName(a, b);
case "ratio":
return (b.uploadRatio || 0) - (a.uploadRatio || 0) || compareName(a, b);
case "size":
return (b.totalSize || 0) - (a.totalSize || 0) || compareName(a, b);
case "peer100": {
const pa = peerStatus(a);
const pb = peerStatus(b);
return (pb.at100 - pa.at100)
|| (pb.connected - pa.connected)
|| ((b.percentDone || 0) - (a.percentDone || 0))
|| compareName(a, b);
}
case "status": { case "status": {
const ra = STATUS_RANK[classify(a).key] ?? 99; const ra = STATUS_RANK[classify(a).key] ?? 99;
const rb = STATUS_RANK[classify(b).key] ?? 99; const rb = STATUS_RANK[classify(b).key] ?? 99;
if (ra !== rb) return ra - rb; if (ra !== rb) return ra - rb;
return (b.percentDone || 0) - (a.percentDone || 0); return (b.percentDone || 0) - (a.percentDone || 0) || compareName(a, b);
} }
default: return 0; default:
return 0;
} }
}); });
return sorted; return sorted;
@@ -180,7 +253,6 @@ function sortTorrents(list) {
/* ---------- Rendering ---------- */ /* ---------- Rendering ---------- */
function renderHeader() { function renderHeader() {
const s = state.stats || {};
const torrents = state.torrents || []; const torrents = state.torrents || [];
let dl = 0, ul = 0; let dl = 0, ul = 0;
for (const t of torrents) { for (const t of torrents) {
@@ -192,138 +264,231 @@ function renderHeader() {
$("#stat-ul").textContent = humanSpeed(ul); $("#stat-ul").textContent = humanSpeed(ul);
} }
function badgeHTML(cls) {
return `<span class="badge ${cls.key}"><span class="dotb"></span>${escapeHTML(cls.label)}</span>`;
}
function escapeHTML(s) {
if (s == null) return "";
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
}
function initial(name) { function initial(name) {
const n = (name || "").trim(); const n = (name || "").trim();
if (!n) return "?"; if (!n) return "?";
// try to grab a meaningful token from the filename
const base = n.replace(/\.[a-z0-9]+$/i, ""); const base = n.replace(/\.[a-z0-9]+$/i, "");
const m = base.match(/[A-Za-z0-9]/); const m = base.match(/[A-Za-z0-9]/);
return m ? m[0].toUpperCase() : "?"; return m ? m[0].toUpperCase() : "?";
} }
function renderCard(t) { function createCard() {
const card = document.createElement("article");
card.className = "card is-new";
card.innerHTML = `
<div class="card-top">
<div class="favicon" data-field="initial"></div>
<div class="card-body">
<div class="card-name" data-field="name"></div>
<div class="card-meta">
<span class="meta-item"><span class="meta-label">Size</span> <span data-field="size"></span></span>
<span class="meta-item"><span class="meta-label">Ratio</span> <span data-field="ratio"></span></span>
<span class="meta-item"><span class="meta-label">Peers</span> <span data-field="peers"></span></span>
<span class="meta-item done-meta hidden" data-field="done-meta-wrap"><span class="meta-label">Done</span> <span data-field="done-meta"></span></span>
</div>
</div>
<div class="card-actions">
<div class="card-status-col">
<span class="badge" data-field="badge"><span class="dotb"></span><span data-field="status-label"></span></span>
<span class="pct" data-field="pct"></span>
</div>
<button type="button" class="action-btn" data-action-button></button>
</div>
</div>
<div class="progress-wrap">
<div class="progress-bar" data-field="progress"></div>
</div>
<div class="card-bottom">
<div class="stat-cell">
<span class="k">Downloaded</span>
<span class="v" data-field="downloaded"></span>
</div>
<div class="stat-cell">
<span class="k">Uploaded</span>
<span class="v" data-field="uploaded"></span>
</div>
<div class="stat-cell">
<span class="k">↓ Speed</span>
<span class="v" data-field="download-speed"></span>
</div>
<div class="stat-cell">
<span class="k">↑ Speed</span>
<span class="v" data-field="upload-speed"></span>
</div>
<div class="stat-cell">
<span class="k">ETA</span>
<span class="v" data-field="eta"></span>
</div>
<div class="stat-cell">
<span class="k">Peers @ 100%</span>
<span class="v mono" data-field="peers-100"></span>
</div>
<div class="stat-cell">
<span class="k">Finished</span>
<span class="v" data-field="finished"></span>
</div>
</div>
<div class="action-error hidden" data-field="action-error"></div>
<div class="error-string hidden" data-field="error-string"></div>
`;
card.addEventListener("animationend", () => card.classList.remove("is-new"), { once: true });
return card;
}
function field(card, name) {
return card.querySelector(`[data-field="${name}"]`);
}
function setText(el, value) {
const text = value == null ? "" : String(value);
if (el.textContent !== text) el.textContent = text;
}
function setValueClass(el, className) {
el.classList.remove("accent", "success", "warn", "dim");
if (className) el.classList.add(className);
}
function updateCard(card, t) {
const id = Number(t.id);
const cls = classify(t); const cls = classify(t);
const pct = humanPct(t.percentDone); const pct = humanPct(t.percentDone);
const pctText = (pct * 100).toFixed(pct === 1 ? 0 : 1) + "%"; const pctText = (pct * 100).toFixed(pct === 1 ? 0 : 1) + "%";
const isDone = pct >= 1.0; const isDone = pct >= 1.0;
const ps = peerStatus(t); const ps = peerStatus(t);
let barClass = "";
if (cls.key === "error") barClass = "error";
else if (isDone) barClass = "done";
else if (cls.key === "paused" || cls.key === "finished") barClass = "paused";
const dl = t.rateDownload || 0; const dl = t.rateDownload || 0;
const ul = t.rateUpload || 0; const ul = t.rateUpload || 0;
const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞"); const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞");
const hasError = cls.key === "error"; const hasError = cls.key === "error";
return ` let barClass = "";
<article class="card ${hasError ? "is-error" : ""}" data-id="${t.id}"> if (hasError) barClass = "error";
<div class="card-top"> else if (isDone) barClass = "done";
<div class="favicon">${escapeHTML(initial(t.name))}</div> else if (cls.key === "paused" || cls.key === "finished") barClass = "paused";
<div class="card-body">
<div class="card-name" title="${escapeHTML(t.name)}">${escapeHTML(t.name)}</div>
<div class="card-meta">
<span class="meta-item"><span class="meta-label">Size</span> ${escapeHTML(humanSize(t.totalSize))}</span>
<span class="meta-item"><span class="meta-label">Ratio</span> ${escapeHTML(humanRatio(t.uploadRatio))}</span>
<span class="meta-item"><span class="meta-label">Peers</span> ${escapeHTML(ps.label)}</span>
${t.doneDate ? `<span class="meta-item"><span class="meta-label">Done</span> ${escapeHTML(formatDate(t.doneDate))}</span>` : ""}
</div>
</div>
<div class="card-status-col">
${badgeHTML(cls)}
<span class="pct">${escapeHTML(pctText)}</span>
</div>
</div>
<div class="progress-wrap"> card.dataset.id = String(id);
<div class="progress-bar ${barClass}" style="width:${(pct * 100).toFixed(2)}%"></div> card.classList.toggle("is-error", hasError);
</div> setText(field(card, "initial"), initial(t.name));
<div class="card-bottom"> const nameEl = field(card, "name");
<div class="stat-cell"> setText(nameEl, t.name || "Unnamed torrent");
<span class="k">Downloaded</span> nameEl.title = t.name || "";
<span class="v">${escapeHTML(humanSize(t.downloadedEver))}${isDone ? ` <span class="dim">/ ${escapeHTML(humanSize(t.totalSize))}</span>` : ""}</span>
</div>
<div class="stat-cell">
<span class="k">Uploaded</span>
<span class="v">${escapeHTML(humanSize(t.uploadedEver))}</span>
</div>
<div class="stat-cell">
<span class="k">↓ Speed</span>
<span class="v ${dl > 0 ? "accent" : "dim"}">${escapeHTML(humanSpeed(dl))}</span>
</div>
<div class="stat-cell">
<span class="k">↑ Speed</span>
<span class="v ${ul > 0 ? "success" : "dim"}">${escapeHTML(humanSpeed(ul))}</span>
</div>
<div class="stat-cell">
<span class="k">ETA</span>
<span class="v">${escapeHTML(eta)}</span>
</div>
<div class="stat-cell">
<span class="k">Peers @ 100%</span>
<span class="v mono">${escapeHTML(`${ps.at100}/${ps.connected}`)}</span>
</div>
<div class="stat-cell">
<span class="k">Finished</span>
<span class="v">${escapeHTML(formatDate(t.doneDate))}</span>
</div>
</div>
${hasError && t.errorString ? `<div class="error-string">${escapeHTML(t.errorString)}</div>` : ""} setText(field(card, "size"), humanSize(t.totalSize));
</article> setText(field(card, "ratio"), humanRatio(t.uploadRatio));
`; setText(field(card, "peers"), ps.label);
const doneMetaWrap = field(card, "done-meta-wrap");
doneMetaWrap.classList.toggle("hidden", !t.doneDate);
setText(field(card, "done-meta"), formatDate(t.doneDate));
const badge = field(card, "badge");
badge.className = `badge ${cls.key}`;
setText(field(card, "status-label"), cls.label);
setText(field(card, "pct"), pctText);
const progress = field(card, "progress");
progress.className = `progress-bar ${barClass}`.trim();
progress.style.width = `${(pct * 100).toFixed(2)}%`;
setText(field(card, "downloaded"), isDone ? `${humanSize(t.downloadedEver)} / ${humanSize(t.totalSize)}` : humanSize(t.downloadedEver));
setText(field(card, "uploaded"), humanSize(t.uploadedEver));
const dlEl = field(card, "download-speed");
setText(dlEl, humanSpeed(dl));
setValueClass(dlEl, dl > 0 ? "accent" : "dim");
const ulEl = field(card, "upload-speed");
setText(ulEl, humanSpeed(ul));
setValueClass(ulEl, ul > 0 ? "success" : "dim");
setText(field(card, "eta"), eta);
setText(field(card, "peers-100"), `${ps.at100}/${ps.connected}`);
setText(field(card, "finished"), formatDate(t.doneDate));
const errorEl = field(card, "error-string");
const errorText = hasError && t.errorString ? t.errorString : "";
setText(errorEl, errorText);
errorEl.classList.toggle("hidden", !errorText);
const action = actionForStatus(cls);
const actionState = actionStates.get(id);
const button = card.querySelector("[data-action-button]");
button.dataset.action = action.action;
button.classList.toggle("pause", action.action === "pause");
button.classList.toggle("resume", action.action === "resume");
button.disabled = Boolean(actionState && actionState.pending);
button.setAttribute("aria-label", `${action.label} ${t.name || "torrent"}`);
setText(button, actionState && actionState.pending ? actionState.pendingText : action.label);
const actionError = field(card, "action-error");
const actionErrorText = actionState && actionState.error ? actionState.error : "";
setText(actionError, actionErrorText);
actionError.classList.toggle("hidden", !actionErrorText);
}
function updateCardByID(id) {
const card = torrentCards.get(String(id));
const torrent = (state.torrents || []).find((t) => Number(t.id) === id);
if (card && torrent) updateCard(card, torrent);
}
function reconcileCards(torrents) {
const list = $("#torrents");
const visible = new Set();
torrents.forEach((t, index) => {
const id = String(t.id);
visible.add(id);
let card = torrentCards.get(id);
if (!card) {
card = createCard();
torrentCards.set(id, card);
}
updateCard(card, t);
const current = list.children[index];
if (current !== card) {
list.insertBefore(card, current || null);
}
});
for (const [id, card] of torrentCards) {
if (!visible.has(id)) {
card.remove();
torrentCards.delete(id);
actionStates.delete(Number(id));
}
}
} }
function renderList() { function renderList() {
const list = $("#torrents");
const empty = $("#empty-state"); const empty = $("#empty-state");
const errEl = $("#error-state"); const errEl = $("#error-state");
const errMsg = $("#error-msg");
errEl.classList.toggle("hidden", !state.lastError);
if (state.lastError) {
errMsg.textContent = state.lastError;
}
if (state.lastError && (!state.torrents || state.torrents.length === 0)) {
reconcileCards([]);
empty.classList.add("hidden");
return;
}
let torrents = sortTorrents(state.torrents || []); let torrents = sortTorrents(state.torrents || []);
const f = state.filter.trim().toLowerCase(); const f = state.filter.trim().toLowerCase();
if (f) torrents = torrents.filter((t) => (t.name || "").toLowerCase().includes(f)); if (f) torrents = torrents.filter((t) => (t.name || "").toLowerCase().includes(f));
errEl.classList.add("hidden"); reconcileCards(torrents);
if (state.lastError) { empty.classList.toggle("hidden", torrents.length !== 0 || Boolean(state.lastError));
list.innerHTML = "";
empty.classList.add("hidden");
errEl.classList.remove("hidden");
$("#error-msg").textContent = state.lastError;
return;
}
if (torrents.length === 0) {
list.innerHTML = "";
empty.classList.remove("hidden");
return;
}
empty.classList.add("hidden");
list.innerHTML = torrents.map(renderCard).join("");
}
function renderError() {
// keep stats list emptied on error
$("#torrents").innerHTML = "";
$("#empty-state").classList.add("hidden");
const errEl = $("#error-state");
errEl.classList.remove("hidden");
$("#error-msg").textContent = state.lastError || "Could not reach the Transmission daemon.";
} }
function renderAll() { function renderAll() {
@@ -348,9 +513,20 @@ function wireEvents() {
}); });
}); });
$("#torrents").addEventListener("click", (e) => {
const button = e.target.closest("[data-action-button]");
if (!button || button.disabled) return;
const card = button.closest(".card");
const id = Number(card && card.dataset.id);
const action = button.dataset.action;
if (!id || !["pause", "resume"].includes(action)) return;
runTorrentAction(id, action);
});
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (!document.hidden) { if (!document.hidden) {
// immediate refresh when returning to the tab
sync(); sync();
} }
}); });

View File

@@ -53,6 +53,7 @@
<button class="sort-btn active" data-sort="status">Status</button> <button class="sort-btn active" data-sort="status">Status</button>
<button class="sort-btn" data-sort="ratio">Ratio</button> <button class="sort-btn" data-sort="ratio">Ratio</button>
<button class="sort-btn" data-sort="size">Size</button> <button class="sort-btn" data-sort="size">Size</button>
<button class="sort-btn" data-sort="peer100">100% Peers</button>
</div> </div>
<div class="refresh-indicator" id="refresh-indicator" title="Auto-refreshes every 3s"> <div class="refresh-indicator" id="refresh-indicator" title="Auto-refreshes every 3s">
<span class="dot"></span> <span class="dot"></span>

View File

@@ -218,8 +218,8 @@ button { font-family: inherit; }
border-radius: var(--radius); border-radius: var(--radius);
padding: 16px; padding: 16px;
transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease;
animation: cardIn .35s cubic-bezier(0.16, 1, 0.3, 1) both;
} }
.card.is-new { animation: cardIn .35s cubic-bezier(0.16, 1, 0.3, 1) both; }
@keyframes cardIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } @keyframes cardIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.card:hover { border-color: var(--border-hover); box-shadow: var(--shadow-2); transform: translateY(-1px); } .card:hover { border-color: var(--border-hover); box-shadow: var(--shadow-2); transform: translateY(-1px); }
.card.is-error { border-color: rgba(248, 113, 113, 0.3); } .card.is-error { border-color: rgba(248, 113, 113, 0.3); }
@@ -265,6 +265,38 @@ button { font-family: inherit; }
font-size: 13px; font-weight: 700; color: var(--text-0); font-size: 13px; font-weight: 700; color: var(--text-0);
font-variant-numeric: tabular-nums; min-width: 44px; text-align: right; font-variant-numeric: tabular-nums; min-width: 44px; text-align: right;
} }
.card-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
}
.action-btn {
min-width: 78px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--bg-3);
color: var(--text-1);
cursor: pointer;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 6px 11px;
text-transform: uppercase;
transition: background .15s, border-color .15s, color .15s, transform .15s, opacity .15s;
}
.action-btn:hover:not(:disabled) {
border-color: var(--border-hover);
color: var(--text-0);
transform: translateY(-1px);
}
.action-btn.pause { color: var(--warn); background: rgba(251, 191, 36, 0.08); border-color: rgba(251, 191, 36, 0.22); }
.action-btn.resume { color: var(--accent); background: var(--accent-soft); border-color: rgba(0, 212, 255, 0.24); }
.action-btn:disabled {
cursor: wait;
opacity: 0.62;
transform: none;
}
.badge { .badge {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
@@ -346,6 +378,15 @@ button { font-family: inherit; }
color: #fca5a5; color: #fca5a5;
font-size: 12px; font-size: 12px;
} }
.action-error {
margin-top: 10px;
padding: 8px 12px;
background: rgba(248, 113, 113, 0.08);
border: 1px solid rgba(248, 113, 113, 0.25);
border-radius: 8px;
color: #fca5a5;
font-size: 12px;
}
/* ---------- Empty / error states ---------- */ /* ---------- Empty / error states ---------- */
.empty-state, .error-state { .empty-state, .error-state {
@@ -384,11 +425,16 @@ button { font-family: inherit; }
.refresh-indicator { margin-left: 0; width: 100%; justify-content: flex-end; } .refresh-indicator { margin-left: 0; width: 100%; justify-content: flex-end; }
.sort-group { width: 100%; overflow-x: auto; } .sort-group { width: 100%; overflow-x: auto; }
.card { padding: 14px; } .card { padding: 14px; }
.card-top { align-items: flex-start; }
.card-actions { flex-direction: column; align-items: flex-end; gap: 7px; }
.card-bottom { grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); } .card-bottom { grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); }
.pct { min-width: 40px; } .pct { min-width: 40px; }
} }
@media (max-width: 440px) { @media (max-width: 440px) {
.brand-text h1 { font-size: 15px; } .brand-text h1 { font-size: 15px; }
.stat-pill .stat-label { display: none; } .stat-pill .stat-label { display: none; }
.card-status-col { flex-direction: column-reverse; align-items: flex-end; gap: 6px; } .card-top { flex-wrap: wrap; }
.card-body { flex-basis: calc(100% - 42px); }
.card-actions { width: 100%; flex-direction: row; justify-content: space-between; }
.card-status-col { flex-direction: column-reverse; align-items: flex-start; gap: 6px; }
} }