mirror of
https://github.com/skidoodle/ncore-stats.git
synced 2026-04-28 15:57:37 +02:00
real
This commit is contained in:
@@ -1,64 +0,0 @@
|
|||||||
name: Docker
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install cosign
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: sigstore/cosign-installer@v3.5.0
|
|
||||||
with:
|
|
||||||
cosign-release: 'v2.1.1'
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
id: build-and-push
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
|
|
||||||
- name: Sign the published Docker image
|
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
env:
|
|
||||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
goreleaser:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: latest
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.22.3'
|
go-version: '1.26.1'
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
|
|||||||
+3
-2
@@ -1,2 +1,3 @@
|
|||||||
.env**
|
.env
|
||||||
data/**
|
data/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
snapshot:
|
||||||
|
version_template: "{{ .Version }}"
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- format: tar.gz
|
||||||
|
name_template: >-
|
||||||
|
{{ .ProjectName }}_
|
||||||
|
{{- .Version }}_
|
||||||
|
{{- .Os }}_
|
||||||
|
{{- .Arch }}
|
||||||
|
|
||||||
|
dockers:
|
||||||
|
- image_templates:
|
||||||
|
- "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||||
|
- "ghcr.io/skidoodle/{{ .ProjectName }}:latest-amd64"
|
||||||
|
dockerfile: Dockerfile.release
|
||||||
|
use: buildx
|
||||||
|
goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
extra_files:
|
||||||
|
- web/
|
||||||
|
|
||||||
|
- image_templates:
|
||||||
|
- "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}-arm64"
|
||||||
|
- "ghcr.io/skidoodle/{{ .ProjectName }}:latest-arm64"
|
||||||
|
dockerfile: Dockerfile.release
|
||||||
|
use: buildx
|
||||||
|
goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
extra_files:
|
||||||
|
- web/
|
||||||
|
|
||||||
|
docker_manifests:
|
||||||
|
- name_template: "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||||
|
- "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}-arm64"
|
||||||
|
- name_template: "ghcr.io/skidoodle/{{ .ProjectName }}:latest"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/skidoodle/{{ .ProjectName }}:latest-amd64"
|
||||||
|
- "ghcr.io/skidoodle/{{ .ProjectName }}:latest-arm64"
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- "^docs:"
|
||||||
|
- "^test:"
|
||||||
+21
-6
@@ -1,16 +1,31 @@
|
|||||||
FROM golang:alpine AS builder
|
FROM golang:1.26.1-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk update && apk add --no-cache git ca-certificates tzdata
|
||||||
|
|
||||||
|
RUN addgroup -S -g 10001 appgroup && \
|
||||||
|
adduser -S -u 10001 -G appgroup appuser
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ncore-stats .
|
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o ncore-stats .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data && chown -R 10001:10001 /app/data
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
|
COPY --from=builder /etc/passwd /etc/passwd
|
||||||
|
COPY --from=builder /etc/group /etc/group
|
||||||
|
COPY --from=builder --chown=10001:10001 /build/ncore-stats /app/ncore-stats
|
||||||
|
COPY --from=builder --chown=10001:10001 /build/web /app/web
|
||||||
|
COPY --from=builder --chown=10001:10001 /app/data /app/data
|
||||||
|
|
||||||
FROM alpine:3
|
|
||||||
RUN apk add --no-cache ca-certificates
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /out/ncore-stats .
|
USER 10001
|
||||||
COPY web ./web
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["./ncore-stats"]
|
CMD ["./ncore-stats"]
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
FROM alpine:latest AS sys-context
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
RUN echo "appuser:x:10001:10001:appuser:/:/sbin/nologin" > /etc/passwd_app \
|
||||||
|
&& echo "appuser:x:10001:appuser" > /etc/group_app
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data && chown -R 10001:10001 /app
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
COPY --from=sys-context /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
COPY --from=sys-context /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
|
COPY --from=sys-context /etc/passwd_app /etc/passwd
|
||||||
|
COPY --from=sys-context /etc/group_app /etc/group
|
||||||
|
COPY --from=sys-context --chown=10001:10001 /app /app
|
||||||
|
|
||||||
|
# Binaries and static files provided by goreleaser
|
||||||
|
COPY --chown=10001:10001 ncore-stats /app/ncore-stats
|
||||||
|
COPY --chown=10001:10001 web /app/web
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
USER 10001
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/ncore-stats"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
ncore-stats:
|
ncore-stats:
|
||||||
image: ghcr.io/skidoodle/ncore-stats:main
|
image: ghcr.io/skidoodle/ncore-stats:latest
|
||||||
container_name: ncore-stats
|
container_name: ncore-stats
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -10,5 +10,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NICK=${NICK}
|
- NICK=${NICK}
|
||||||
- PASSWORD=${PASS}
|
- PASSWORD=${PASS}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
+67
-1
@@ -19,16 +19,82 @@ func initDB(cfg *Configuration) *sql.DB {
|
|||||||
|
|
||||||
schemas := []string{
|
schemas := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT UNIQUE, profile_id TEXT);`,
|
`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT UNIQUE, profile_id TEXT);`,
|
||||||
`CREATE TABLE IF NOT EXISTS profile_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, timestamp DATETIME, rank INTEGER, upload TEXT, current_upload TEXT, current_download TEXT, points INTEGER, seeding_count INTEGER, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE);`,
|
`CREATE TABLE IF NOT EXISTS profile_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
timestamp DATETIME,
|
||||||
|
rank INTEGER,
|
||||||
|
upload TEXT,
|
||||||
|
upload_bytes INTEGER,
|
||||||
|
current_upload TEXT,
|
||||||
|
current_download TEXT,
|
||||||
|
points INTEGER,
|
||||||
|
seeding_count INTEGER,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_history_user_ts ON profile_history(user_id, timestamp);`,
|
||||||
}
|
}
|
||||||
for _, s := range schemas {
|
for _, s := range schemas {
|
||||||
if _, err := db.Exec(s); err != nil {
|
if _, err := db.Exec(s); err != nil {
|
||||||
logrus.Fatalf("Schema error: %v", err)
|
logrus.Fatalf("Schema error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrate(db)
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrate(db *sql.DB) {
|
||||||
|
var columnExists bool
|
||||||
|
_ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('profile_history') WHERE name='upload_bytes'").Scan(&columnExists)
|
||||||
|
if !columnExists {
|
||||||
|
logrus.Info("Migrating: Adding upload_bytes column...")
|
||||||
|
_, err := db.Exec("ALTER TABLE profile_history ADD COLUMN upload_bytes INTEGER")
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("Migration failed (add column): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.Info("Migrating: Backfilling upload_bytes from existing strings...")
|
||||||
|
|
||||||
|
type updateRow struct {
|
||||||
|
id int64
|
||||||
|
bytes int64
|
||||||
|
}
|
||||||
|
var updates []updateRow
|
||||||
|
|
||||||
|
rows, err := db.Query("SELECT id, upload FROM profile_history WHERE upload_bytes IS NULL")
|
||||||
|
if err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var upload string
|
||||||
|
if err := rows.Scan(&id, &upload); err == nil {
|
||||||
|
updates = append(updates, updateRow{id: id, bytes: parseToBytes(upload)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("Transaction start failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stmt, _ := tx.Prepare("UPDATE profile_history SET upload_bytes = ? WHERE id = ?")
|
||||||
|
for _, up := range updates {
|
||||||
|
_, _ = stmt.Exec(up.bytes, up.id)
|
||||||
|
}
|
||||||
|
stmt.Close()
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
logrus.Errorf("Transaction commit failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logrus.Info("Migration complete.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) getLatest() ([]ProfileData, error) {
|
func (s *State) getLatest() ([]ProfileData, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT u.display_name, ph.timestamp, ph.rank, ph.upload, ph.current_upload, ph.current_download, ph.points, ph.seeding_count
|
SELECT u.display_name, ph.timestamp, ph.rank, ph.upload, ph.current_upload, ph.current_download, ph.points, ph.seeding_count
|
||||||
|
|||||||
+42
-4
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -27,7 +28,7 @@ func (s *State) historyHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Owner required", http.StatusBadRequest)
|
http.Error(w, "Owner required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := s.db.Query(`SELECT u.display_name, ph.timestamp, ph.rank, ph.upload, ph.current_upload, ph.current_download, ph.points, ph.seeding_count FROM profile_history ph JOIN users u ON ph.user_id = u.id WHERE u.display_name = ? ORDER BY ph.timestamp ASC`, owner)
|
rows, err := s.db.Query(`SELECT ph.timestamp, ph.rank, ph.upload, ph.points, ph.seeding_count FROM profile_history ph JOIN users u ON ph.user_id = u.id WHERE u.display_name = ? ORDER BY ph.timestamp ASC`, owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -37,8 +38,7 @@ func (s *State) historyHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var history []ProfileData
|
var history []ProfileData
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p ProfileData
|
var p ProfileData
|
||||||
if err := rows.Scan(&p.Owner, &p.Timestamp, &p.Rank, &p.Upload, &p.CurrentUpload, &p.CurrentDownload, &p.Points, &p.SeedingCount); err != nil {
|
if err := rows.Scan(&p.Timestamp, &p.Rank, &p.Upload, &p.Points, &p.SeedingCount); err != nil {
|
||||||
logrus.Errorf("Scan history failed: %v", err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
history = append(history, p)
|
history = append(history, p)
|
||||||
@@ -53,9 +53,47 @@ func (s *State) historyModalHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Owner required", http.StatusBadRequest)
|
http.Error(w, "Owner required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, `<div id="chart-data-container" data-owner="%s" x-init="renderChart('%s')"></div>`, owner, owner)
|
|
||||||
|
rows, err := s.db.Query(`SELECT ph.timestamp, ph.rank, ph.upload_bytes, ph.points, ph.seeding_count
|
||||||
|
FROM profile_history ph
|
||||||
|
JOIN users u ON ph.user_id = u.id
|
||||||
|
WHERE u.display_name = ?
|
||||||
|
ORDER BY ph.timestamp ASC`, owner)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "DB Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
res := CompactHistory{Owner: owner}
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
ts time.Time
|
||||||
|
rank int
|
||||||
|
uploadBytes int64
|
||||||
|
points int
|
||||||
|
seeding int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&ts, &rank, &uploadBytes, &points, &seeding); err == nil {
|
||||||
|
res.Timestamp = append(res.Timestamp, ts.Unix()*1000)
|
||||||
|
res.Rank = append(res.Rank, rank)
|
||||||
|
|
||||||
|
tib := float64(uploadBytes) / (1024 * 1024 * 1024 * 1024)
|
||||||
|
res.Upload = append(res.Upload, tib)
|
||||||
|
|
||||||
|
res.Points = append(res.Points, points)
|
||||||
|
res.Seeding = append(res.Seeding, seeding)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dataJSON, _ := json.Marshal(res)
|
||||||
|
|
||||||
|
fmt.Fprintf(w, `
|
||||||
|
<div id="chart-mount"
|
||||||
|
style="height: 100%%; width: 100%%;"
|
||||||
|
x-init='renderChart(%s)'>
|
||||||
|
</div>`, string(dataJSON))
|
||||||
|
}
|
||||||
func (s *State) rootHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *State) rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type ProfileData struct {
|
|||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
Rank int `json:"rank"`
|
Rank int `json:"rank"`
|
||||||
Upload string `json:"upload"`
|
Upload string `json:"upload"`
|
||||||
|
UploadBytes int64 `json:"upload_bytes"`
|
||||||
CurrentUpload string `json:"current_upload"`
|
CurrentUpload string `json:"current_upload"`
|
||||||
CurrentDownload string `json:"current_download"`
|
CurrentDownload string `json:"current_download"`
|
||||||
Points int `json:"points"`
|
Points int `json:"points"`
|
||||||
@@ -43,3 +44,13 @@ type State struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CompactHistory represents an optimized, columnar history format.
|
||||||
|
type CompactHistory struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Timestamp []int64 `json:"t"`
|
||||||
|
Rank []int `json:"r"`
|
||||||
|
Upload []float64 `json:"u"`
|
||||||
|
Points []int `json:"p"`
|
||||||
|
Seeding []int `json:"s"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ A simple Go project to scrape and track profile statistics (rank, upload, downlo
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
ncore-stats:
|
ncore-stats:
|
||||||
image: ghcr.io/skidoodle/ncore-stats:main
|
image: ghcr.io/skidoodle/ncore-stats:latest
|
||||||
container_name: ncore-stats
|
container_name: ncore-stats
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+28
-2
@@ -76,8 +76,8 @@ func (s *State) scrapeAll(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(`INSERT INTO profile_history(user_id, timestamp, rank, upload, current_upload, current_download, points, seeding_count) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
|
_, err = s.db.Exec(`INSERT INTO profile_history(user_id, timestamp, rank, upload, upload_bytes, current_upload, current_download, points, seeding_count) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
user.ID, profile.Timestamp, profile.Rank, profile.Upload, profile.CurrentUpload, profile.CurrentDownload, profile.Points, profile.SeedingCount)
|
user.ID, profile.Timestamp, profile.Rank, profile.Upload, profile.UploadBytes, profile.CurrentUpload, profile.CurrentDownload, profile.Points, profile.SeedingCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("[%s] DB log failed: %v", user.DisplayName, err)
|
logrus.Errorf("[%s] DB log failed: %v", user.DisplayName, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -123,6 +123,7 @@ func (s *State) fetchProfile(ctx context.Context, user User) (*ProfileData, erro
|
|||||||
p.Rank, _ = strconv.Atoi(strings.TrimSuffix(value, "."))
|
p.Rank, _ = strconv.Atoi(strings.TrimSuffix(value, "."))
|
||||||
} else if strings.Contains(label, "feltöltés") { // Upload
|
} else if strings.Contains(label, "feltöltés") { // Upload
|
||||||
p.Upload = value
|
p.Upload = value
|
||||||
|
p.UploadBytes = parseToBytes(value)
|
||||||
} else if strings.Contains(label, "pontok") { // Points
|
} else if strings.Contains(label, "pontok") { // Points
|
||||||
p.Points, _ = strconv.Atoi(strings.ReplaceAll(value, " ", ""))
|
p.Points, _ = strconv.Atoi(strings.ReplaceAll(value, " ", ""))
|
||||||
}
|
}
|
||||||
@@ -146,3 +147,28 @@ func (s *State) fetchProfile(ctx context.Context, user User) (*ProfileData, erro
|
|||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseToBytes(value string) int64 {
|
||||||
|
valStr := strings.ReplaceAll(value, ",", "")
|
||||||
|
parts := strings.Fields(valStr)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
num, _ := strconv.ParseFloat(parts[0], 64)
|
||||||
|
unit := strings.ToLower(parts[1])
|
||||||
|
|
||||||
|
var multiplier float64
|
||||||
|
switch unit {
|
||||||
|
case "tib":
|
||||||
|
multiplier = 1024 * 1024 * 1024 * 1024
|
||||||
|
case "gib":
|
||||||
|
multiplier = 1024 * 1024 * 1024
|
||||||
|
case "mib":
|
||||||
|
multiplier = 1024 * 1024
|
||||||
|
case "kib":
|
||||||
|
multiplier = 1024
|
||||||
|
default:
|
||||||
|
multiplier = 1
|
||||||
|
}
|
||||||
|
return int64(num * multiplier)
|
||||||
|
}
|
||||||
|
|||||||
+9
-24
@@ -6,51 +6,35 @@ const config = {
|
|||||||
|
|
||||||
let currentChart = null;
|
let currentChart = null;
|
||||||
|
|
||||||
async function renderChart(owner) {
|
async function renderChart(data) {
|
||||||
const root = document.getElementById('modal-stats-root');
|
const root = document.getElementById('modal-stats-root');
|
||||||
if (!root) return;
|
if (!root || !data) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${config.api.history}${encodeURIComponent(owner)}`); if (!response.ok) throw new Error('Network error');
|
if (!data.t || data.t.length === 0) {
|
||||||
const historyData = await response.json();
|
|
||||||
|
|
||||||
if (!historyData || historyData.length === 0) {
|
|
||||||
root.innerHTML = '<p class="stat-label" style="text-align: center; padding: 2rem; color: var(--muted);">No history available.</p>';
|
root.innerHTML = '<p class="stat-label" style="text-align: center; padding: 2rem; color: var(--muted);">No history available.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parseUploadValue = (value) => {
|
|
||||||
if (typeof value !== 'string') return 0;
|
|
||||||
const num = parseFloat(value.replace(/,/g, '').replace(/TiB|GiB|MiB|KiB|B/i, '').trim());
|
|
||||||
if (isNaN(num)) return 0;
|
|
||||||
const lowerVal = value.toLowerCase();
|
|
||||||
if (lowerVal.includes('tib')) return num;
|
|
||||||
if (lowerVal.includes('gib')) return num / 1024;
|
|
||||||
if (lowerVal.includes('mib')) return num / 1024 / 1024;
|
|
||||||
if (lowerVal.includes('kib')) return num / 1024 / 1024 / 1024;
|
|
||||||
return num / 1024 / 1024 / 1024 / 1024;
|
|
||||||
};
|
|
||||||
|
|
||||||
const series = [
|
const series = [
|
||||||
{
|
{
|
||||||
name: 'Upload',
|
name: 'Upload',
|
||||||
data: historyData.map(r => ({ x: new Date(r.timestamp).getTime(), y: parseUploadValue(r.upload) }))
|
data: data.t.map((ts, i) => ({ x: ts, y: data.u[i] }))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Rank',
|
name: 'Rank',
|
||||||
data: historyData.map(r => ({ x: new Date(r.timestamp).getTime(), y: r.rank }))
|
data: data.t.map((ts, i) => ({ x: ts, y: data.r[i] }))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Points',
|
name: 'Points',
|
||||||
data: historyData.map(r => ({ x: new Date(r.timestamp).getTime(), y: r.points }))
|
data: data.t.map((ts, i) => ({ x: ts, y: data.p[i] }))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Seeding',
|
name: 'Seeding',
|
||||||
data: historyData.map(r => ({ x: new Date(r.timestamp).getTime(), y: r.seeding_count }))
|
data: data.t.map((ts, i) => ({ x: ts, y: data.s[i] }))
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
root.innerHTML = '<div id="chart-mount" style="height: 100%; width: 100%;"></div>';
|
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
series: series,
|
series: series,
|
||||||
chart: {
|
chart: {
|
||||||
@@ -61,7 +45,7 @@ async function renderChart(owner) {
|
|||||||
foreColor: '#71717a',
|
foreColor: '#71717a',
|
||||||
fontFamily: 'Inter, system-ui, sans-serif',
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
toolbar: { show: false },
|
toolbar: { show: false },
|
||||||
animations: { enabled: true, easing: 'easeinout', speed: 800 }
|
animations: { enabled: false }
|
||||||
},
|
},
|
||||||
responsive: [
|
responsive: [
|
||||||
{
|
{
|
||||||
@@ -166,6 +150,7 @@ async function renderChart(owner) {
|
|||||||
currentChart.render();
|
currentChart.render();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
root.innerHTML = `<div class="spinner-container"><p class="stat-label" style="color: #ef4444;">Error: ${e.message}</p></div>`;
|
root.innerHTML = `<div class="spinner-container"><p class="stat-label" style="color: #ef4444;">Error: ${e.message}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,50 @@ main {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-range {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-range:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top: 3px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user