feat: machine status page, speed test, live presence, forwards without ufw (0.0.5)

Status page per machine: CPU load, RAM and disk of the client, sampled
by the agent only while the page is open and visible (one reading
serves every open tab), plus a button that has the agent measure its
link to the host: ping, down and up.

Online/offline now comes from the control polls the hub holds open, so
a dropped agent shows offline at once instead of 45 s later, and the
dashboard keeps the status column live.

A forward rule can be created "without ufw": the panel opens nothing in
ufw for it and so revokes nothing when it is deleted. The checkbox is
shown only while the ufw integration is on.

Dashboard row actions are icons; a status action is added.
This commit is contained in:
redtea committed 2026-10-13 18:56:15 +03:00
1 parent 7f4d645f19
commit 86cb0226ab
22 files changed
+920 -34

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
package agent
import (
"context"
"crypto/rand"
"encoding/json"
"io"
"net/http"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
const (
// speedWindow is how long each direction is measured for.
speedWindow = 3 * time.Second
// speedUpCap bounds the upload, whatever the link does in speedWindow.
speedUpCap = 48 << 20
)
// doSpeed measures the agent's HTTP link to the server: round-trip time of an
// empty request, then how much flows each way in speedWindow.
func (a *Agent) doSpeed(ctx context.Context) proto.TaskResult {
res, err := a.measureSpeed(ctx)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
data, _ := json.Marshal(res)
return proto.TaskResult{OK: true, Data: data}
}
func (a *Agent) measureSpeed(ctx context.Context) (proto.SysSpeedResult, error) {
var res proto.SysSpeedResult
// Ping: the first request may pay for a new connection, so keep the best.
for i := 0; i < 4; i++ {
start := time.Now()
resp, err := a.speedRequest(ctx, http.MethodGet, "/api/agent/speed/down?bytes=0", nil)
if err != nil {
return res, err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
ms := float64(time.Since(start).Microseconds()) / 1000
if i == 0 || ms < res.PingMs {
res.PingMs = ms
}
}
// Down: read what the server streams until the window closes.
dctx, cancel := context.WithTimeout(ctx, speedWindow)
resp, err := a.speedRequest(dctx, http.MethodGet, "/api/agent/speed/down", nil)
if err != nil {
cancel()
return res, err
}
start := time.Now()
n, _ := io.Copy(io.Discard, resp.Body)
elapsed := time.Since(start)
resp.Body.Close()
cancel()
res.DownBps = bps(n, elapsed)
// Up: send until the window closes or the cap is hit.
body := &speedSource{deadline: time.Now().Add(speedWindow), left: speedUpCap}
start = time.Now()
resp, err = a.speedRequest(ctx, http.MethodPost, "/api/agent/speed/up", body)
if err != nil {
return res, err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
res.UpBps = bps(int64(speedUpCap-body.left), time.Since(start))
return res, nil
}
func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, a.cfg.Host+path, body)
if err != nil {
return nil, err
}
a.authHeaders(req)
resp, err := a.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
resp.Body.Close()
return nil, errStatus(resp.StatusCode)
}
return resp, nil
}
func bps(n int64, d time.Duration) float64 {
if d <= 0 {
return 0
}
return float64(n) * 8 / d.Seconds()
}
// speedSource is an upload body of incompressible bytes that ends at the
// deadline or after `left` bytes.
type speedSource struct {
deadline time.Time
left int
block []byte
}
func (s *speedSource) Read(p []byte) (int, error) {
if s.left <= 0 || !time.Now().Before(s.deadline) {
return 0, io.EOF
}
if s.block == nil {
s.block = make([]byte, 64<<10)
rand.Read(s.block)
}
n := copy(p, s.block)
if n > s.left {
n = s.left
}
s.left -= n
return n, nil
}