changes
This commit is contained in:
81
.gitea/workflows/main.yaml
Normal file
81
.gitea/workflows/main.yaml
Normal 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
141
AGENTS.md
@@ -1,99 +1,58 @@
|
||||
# Transmission Manager — AGENTS.md
|
||||
# Transmission Manager - Agent Guide
|
||||
|
||||
## Project Overview
|
||||
A lightweight single-binary Go web app that provides a beautiful dark-mode SPA for managing/viewing torrents in a Transmission RPC client.
|
||||
## First Rule
|
||||
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
|
||||
- **Backend**: Go (stdlib net/http only, no external dependencies). Serves on port 8080.
|
||||
- `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.
|
||||
## Quick Summary
|
||||
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.
|
||||
|
||||
## Transmission RPC Details
|
||||
- RPC URL: `http://192.168.20.22:3210/transmission/rpc`
|
||||
- No auth required (open on LAN)
|
||||
- The RPC requires a session-ID handshake:
|
||||
1. POST to the RPC URL
|
||||
2. If response is 409, read the `X-Transmission-Session-Id` header from the response
|
||||
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)
|
||||
## Stack
|
||||
- Backend: Go 1.24, standard library only.
|
||||
- Frontend: Vanilla HTML, CSS, and JavaScript. No framework or build step.
|
||||
- Static assets: embedded with Go `embed` from `web/`.
|
||||
- Runtime port: `8080`.
|
||||
- Container: multi-stage Docker build from `golang:1.24-alpine` to `alpine:latest`.
|
||||
|
||||
## Design Requirements (CRITICAL)
|
||||
- **Dark mode only** — deep dark background (#0a0a0f or similar)
|
||||
- **Modern, stylish, clean** — think Linear, Vercel, Raycast aesthetic
|
||||
- **Accent color**: Use a vibrant accent (cyan/teal #00d4ff or purple #8b5cf6)
|
||||
- **Typography**: System font stack, -apple-system, Inter-like
|
||||
- **Torrent cards or rows** with:
|
||||
- Name (truncated with ellipsis if long)
|
||||
- Size (human-readable: GB, MB, etc.)
|
||||
- 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
|
||||
## Current Files
|
||||
- `project-context.md` - canonical project context and implementation rules for agents.
|
||||
- `main.go` - HTTP server, embedded static file serving, environment-driven Transmission RPC client, connection warning page, API routes.
|
||||
- `web/index.html` - SPA shell and toolbar controls.
|
||||
- `web/app.js` - frontend state, polling, in-place rendering, sorting, pause/resume actions.
|
||||
- `web/styles.css` - dark-only UI theme and responsive layout.
|
||||
- `go.mod` - module `transmission-manager`, Go 1.24.
|
||||
- `Dockerfile` - production container build.
|
||||
|
||||
## Code Structure
|
||||
```
|
||||
main.go — Go backend (HTTP server, Transmission RPC proxy, static file serving via embed)
|
||||
web/
|
||||
index.html — SPA shell
|
||||
app.js — Frontend logic (fetch API, render, auto-refresh)
|
||||
styles.css — All styling
|
||||
Dockerfile — Multi-stage: golang:1.24-alpine (build) → alpine:latest (runtime)
|
||||
go.mod — module transmission-manager, go 1.24
|
||||
```
|
||||
## Core API Surface
|
||||
- `GET /` serves a Transmission connection warning page when setup fails; otherwise it serves the embedded frontend.
|
||||
- `GET /api/torrents` returns Transmission torrent data.
|
||||
- `GET /api/stats` returns global session stats.
|
||||
- `POST /api/torrents/{id}/pause` maps to Transmission `torrent-stop`.
|
||||
- `POST /api/torrents/{id}/resume` maps to queue-respecting Transmission `torrent-start`.
|
||||
|
||||
## Go Implementation Notes
|
||||
- Use `embed` package to embed web/ directory into the binary
|
||||
- Transmission RPC communication via net/http client
|
||||
- Implement session-ID handshake with retry on 409
|
||||
- Use sync.Mutex to protect session-ID cache
|
||||
- Format numbers nicely (human-readable sizes) in the frontend, not backend
|
||||
- CORS not needed (same origin)
|
||||
- The Go server should be minimal and fast
|
||||
## Implementation Guidelines
|
||||
- Keep the backend stdlib-only. Do not add Gin, Fiber, Chi, Gorilla, or other Go web frameworks.
|
||||
- Keep the frontend dependency-free. Do not add React, Vue, Svelte, bundlers, package managers, or transpilers.
|
||||
- Preserve the single-binary model: static frontend files should remain embedded via `embed`.
|
||||
- Format display numbers in the frontend unless there is a clear backend reason.
|
||||
- Preserve in-place frontend updates during polling. Do not replace the whole torrent list on each refresh.
|
||||
- Keep polling lightweight and avoid overlapping refresh requests.
|
||||
- Keep UI dark-only, modern, compact, responsive, and based on CSS variables.
|
||||
|
||||
## Dockerfile
|
||||
```dockerfile
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o transmission-manager .
|
||||
## Transmission RPC Notes
|
||||
- Default RPC URL: `http://192.168.20.22:3210/transmission/rpc`.
|
||||
- Override the RPC URL with `TRANSMISSION_URL`.
|
||||
- Optional HTTP Basic Auth credentials come from `TRANSMISSION_RPC_USERNAME` and `TRANSMISSION_RPC_PASSWORD`; unset, empty, or literal `null` values mean no credential value.
|
||||
- Handle the Transmission session-ID handshake:
|
||||
1. POST to the RPC URL.
|
||||
2. If the response is `409`, read `X-Transmission-Session-Id`.
|
||||
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
|
||||
RUN apk add --no-cache ca-certificates
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/transmission-manager .
|
||||
EXPOSE 8080
|
||||
CMD ["./transmission-manager"]
|
||||
```
|
||||
|
||||
## 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
|
||||
## Do Not
|
||||
- Do not add authentication unless explicitly requested.
|
||||
- Do not add CI/CD or GitHub Actions.
|
||||
- Do not create a GitHub repository.
|
||||
- Do not vendor dependencies.
|
||||
- Do not add broad tests or infrastructure for small changes; keep verification proportional.
|
||||
|
||||
338
main.go
338
main.go
@@ -6,10 +6,15 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -18,31 +23,96 @@ import (
|
||||
var webFS embed.FS
|
||||
|
||||
const (
|
||||
rpcURL = "http://192.168.20.22:3210/transmission/rpc"
|
||||
defaultRPCURL = "http://192.168.20.22:3210/transmission/rpc"
|
||||
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.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
rpcURL string
|
||||
username string
|
||||
password string
|
||||
mu sync.Mutex
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
func NewClient(rpcURL, username, password string) *Client {
|
||||
return &Client{
|
||||
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.
|
||||
func (c *Client) rpc(ctx context.Context, body []byte) ([]byte, 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 {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.username != "" || c.password != "" {
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
}
|
||||
if sessionID != "" {
|
||||
req.Header.Set(sessionHeader, sessionID)
|
||||
}
|
||||
@@ -103,6 +173,7 @@ type rpcRequest struct {
|
||||
|
||||
type rpcArgs struct {
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
IDs []int `json:"ids,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
@@ -147,6 +218,30 @@ func (c *Client) Stats(ctx context.Context) (json.RawMessage, error) {
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = 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})
|
||||
}
|
||||
|
||||
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() {
|
||||
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()
|
||||
|
||||
// API routes
|
||||
mux.HandleFunc("/api/torrents", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTransmissionClient(w, client, setupErr) {
|
||||
return
|
||||
}
|
||||
args, err := client.Torrents(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("torrents: %v", err)
|
||||
@@ -173,8 +497,12 @@ func main() {
|
||||
}
|
||||
writeJSON(w, args)
|
||||
})
|
||||
mux.HandleFunc("/api/torrents/", handleTorrentAction(client, setupErr))
|
||||
|
||||
mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTransmissionClient(w, client, setupErr) {
|
||||
return
|
||||
}
|
||||
args, err := client.Stats(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("stats: %v", err)
|
||||
@@ -190,7 +518,7 @@ func main() {
|
||||
log.Fatalf("embed sub: %v", err)
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(webRoot))
|
||||
mux.Handle("/", fileServer)
|
||||
mux.Handle("/", handleStaticFiles(fileServer, client, setupErr))
|
||||
|
||||
addr := ":8080"
|
||||
log.Printf("transmission-manager listening on %s", addr)
|
||||
|
||||
123
project-context.md
Normal file
123
project-context.md
Normal 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.
|
||||
366
web/app.js
366
web/app.js
@@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
/* ============================================================
|
||||
Transmission Manager — frontend
|
||||
Vanilla SPA. Auto-refreshes every 3s.
|
||||
Transmission Manager - frontend
|
||||
Vanilla SPA. Auto-refreshes every 3s with in-place updates.
|
||||
============================================================ */
|
||||
|
||||
const REFRESH_MS = 3000;
|
||||
@@ -24,18 +24,34 @@ const state = {
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
const torrentCards = new Map();
|
||||
const actionStates = new Map();
|
||||
let syncInFlight = false;
|
||||
let syncQueued = false;
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
/* ---------- Fetching ---------- */
|
||||
async function fetchJSON(url) {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
return res.json();
|
||||
async function fetchJSON(url, options = {}) {
|
||||
const res = await fetch(url, { cache: "no-store", ...options });
|
||||
const text = await res.text();
|
||||
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() {
|
||||
const data = await fetchJSON("/api/torrents");
|
||||
// Transmission returns { torrents: [...] }
|
||||
state.torrents = (data && data.torrents) || [];
|
||||
}
|
||||
|
||||
@@ -45,17 +61,29 @@ async function loadStats() {
|
||||
}
|
||||
|
||||
async function sync() {
|
||||
if (syncInFlight) {
|
||||
syncQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
syncInFlight = true;
|
||||
setSyncing(true);
|
||||
try {
|
||||
await Promise.all([loadTorrents(), loadStats()]);
|
||||
state.lastError = null;
|
||||
renderAll();
|
||||
} catch (err) {
|
||||
state.lastError = err.message || String(err);
|
||||
renderError();
|
||||
} finally {
|
||||
renderAll();
|
||||
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";
|
||||
}
|
||||
|
||||
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 ---------- */
|
||||
function humanSize(bytes) {
|
||||
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 ---------- */
|
||||
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) {
|
||||
const key = state.sortKey;
|
||||
const sorted = list.slice();
|
||||
sorted.sort((a, b) => {
|
||||
switch (key) {
|
||||
case "name": return a.name.localeCompare(b.name);
|
||||
case "progress": return (b.percentDone || 0) - (a.percentDone || 0);
|
||||
case "ratio": return (b.uploadRatio || 0) - (a.uploadRatio || 0);
|
||||
case "size": return (b.totalSize || 0) - (a.totalSize || 0);
|
||||
case "name":
|
||||
return compareName(a, b);
|
||||
case "progress":
|
||||
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": {
|
||||
const ra = STATUS_RANK[classify(a).key] ?? 99;
|
||||
const rb = STATUS_RANK[classify(b).key] ?? 99;
|
||||
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;
|
||||
@@ -180,7 +253,6 @@ function sortTorrents(list) {
|
||||
|
||||
/* ---------- Rendering ---------- */
|
||||
function renderHeader() {
|
||||
const s = state.stats || {};
|
||||
const torrents = state.torrents || [];
|
||||
let dl = 0, ul = 0;
|
||||
for (const t of torrents) {
|
||||
@@ -192,138 +264,231 @@ function renderHeader() {
|
||||
$("#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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[c]);
|
||||
}
|
||||
|
||||
function initial(name) {
|
||||
const n = (name || "").trim();
|
||||
if (!n) return "?";
|
||||
// try to grab a meaningful token from the filename
|
||||
const base = n.replace(/\.[a-z0-9]+$/i, "");
|
||||
const m = base.match(/[A-Za-z0-9]/);
|
||||
return m ? m[0].toUpperCase() : "?";
|
||||
}
|
||||
|
||||
function renderCard(t) {
|
||||
const cls = classify(t);
|
||||
const pct = humanPct(t.percentDone);
|
||||
const pctText = (pct * 100).toFixed(pct === 1 ? 0 : 1) + "%";
|
||||
const isDone = pct >= 1.0;
|
||||
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 ul = t.rateUpload || 0;
|
||||
const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞");
|
||||
|
||||
const hasError = cls.key === "error";
|
||||
|
||||
return `
|
||||
<article class="card ${hasError ? "is-error" : ""}" data-id="${t.id}">
|
||||
function createCard() {
|
||||
const card = document.createElement("article");
|
||||
card.className = "card is-new";
|
||||
card.innerHTML = `
|
||||
<div class="card-top">
|
||||
<div class="favicon">${escapeHTML(initial(t.name))}</div>
|
||||
<div class="favicon" data-field="initial"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-name" title="${escapeHTML(t.name)}">${escapeHTML(t.name)}</div>
|
||||
<div class="card-name" data-field="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>` : ""}
|
||||
<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">
|
||||
${badgeHTML(cls)}
|
||||
<span class="pct">${escapeHTML(pctText)}</span>
|
||||
<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 ${barClass}" style="width:${(pct * 100).toFixed(2)}%"></div>
|
||||
<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">${escapeHTML(humanSize(t.downloadedEver))}${isDone ? ` <span class="dim">/ ${escapeHTML(humanSize(t.totalSize))}</span>` : ""}</span>
|
||||
<span class="v" data-field="downloaded"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Uploaded</span>
|
||||
<span class="v">${escapeHTML(humanSize(t.uploadedEver))}</span>
|
||||
<span class="v" data-field="uploaded"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">↓ Speed</span>
|
||||
<span class="v ${dl > 0 ? "accent" : "dim"}">${escapeHTML(humanSpeed(dl))}</span>
|
||||
<span class="v" data-field="download-speed"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">↑ Speed</span>
|
||||
<span class="v ${ul > 0 ? "success" : "dim"}">${escapeHTML(humanSpeed(ul))}</span>
|
||||
<span class="v" data-field="upload-speed"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">ETA</span>
|
||||
<span class="v">${escapeHTML(eta)}</span>
|
||||
<span class="v" data-field="eta"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Peers @ 100%</span>
|
||||
<span class="v mono">${escapeHTML(`${ps.at100}/${ps.connected}`)}</span>
|
||||
<span class="v mono" data-field="peers-100"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Finished</span>
|
||||
<span class="v">${escapeHTML(formatDate(t.doneDate))}</span>
|
||||
<span class="v" data-field="finished"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${hasError && t.errorString ? `<div class="error-string">${escapeHTML(t.errorString)}</div>` : ""}
|
||||
</article>
|
||||
<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 pct = humanPct(t.percentDone);
|
||||
const pctText = (pct * 100).toFixed(pct === 1 ? 0 : 1) + "%";
|
||||
const isDone = pct >= 1.0;
|
||||
const ps = peerStatus(t);
|
||||
const dl = t.rateDownload || 0;
|
||||
const ul = t.rateUpload || 0;
|
||||
const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞");
|
||||
const hasError = cls.key === "error";
|
||||
|
||||
let barClass = "";
|
||||
if (hasError) barClass = "error";
|
||||
else if (isDone) barClass = "done";
|
||||
else if (cls.key === "paused" || cls.key === "finished") barClass = "paused";
|
||||
|
||||
card.dataset.id = String(id);
|
||||
card.classList.toggle("is-error", hasError);
|
||||
setText(field(card, "initial"), initial(t.name));
|
||||
|
||||
const nameEl = field(card, "name");
|
||||
setText(nameEl, t.name || "Unnamed torrent");
|
||||
nameEl.title = t.name || "";
|
||||
|
||||
setText(field(card, "size"), humanSize(t.totalSize));
|
||||
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() {
|
||||
const list = $("#torrents");
|
||||
const empty = $("#empty-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 || []);
|
||||
|
||||
const f = state.filter.trim().toLowerCase();
|
||||
if (f) torrents = torrents.filter((t) => (t.name || "").toLowerCase().includes(f));
|
||||
|
||||
errEl.classList.add("hidden");
|
||||
if (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.";
|
||||
reconcileCards(torrents);
|
||||
empty.classList.toggle("hidden", torrents.length !== 0 || Boolean(state.lastError));
|
||||
}
|
||||
|
||||
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", () => {
|
||||
if (!document.hidden) {
|
||||
// immediate refresh when returning to the tab
|
||||
sync();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<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="size">Size</button>
|
||||
<button class="sort-btn" data-sort="peer100">100% Peers</button>
|
||||
</div>
|
||||
<div class="refresh-indicator" id="refresh-indicator" title="Auto-refreshes every 3s">
|
||||
<span class="dot"></span>
|
||||
|
||||
@@ -218,8 +218,8 @@ button { font-family: inherit; }
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
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; } }
|
||||
.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); }
|
||||
@@ -265,6 +265,38 @@ button { font-family: inherit; }
|
||||
font-size: 13px; font-weight: 700; color: var(--text-0);
|
||||
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 {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
@@ -346,6 +378,15 @@ button { font-family: inherit; }
|
||||
color: #fca5a5;
|
||||
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-state, .error-state {
|
||||
@@ -384,11 +425,16 @@ button { font-family: inherit; }
|
||||
.refresh-indicator { margin-left: 0; width: 100%; justify-content: flex-end; }
|
||||
.sort-group { width: 100%; overflow-x: auto; }
|
||||
.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)); }
|
||||
.pct { min-width: 40px; }
|
||||
}
|
||||
@media (max-width: 440px) {
|
||||
.brand-text h1 { font-size: 15px; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user