Wrap up 1.1.0
This commit is contained in:
@@ -166,7 +166,7 @@ func (m *Manager) performMount(provider, webdavURL string) error {
|
||||
Args: mountArgs,
|
||||
}
|
||||
|
||||
_, err := m.makeRequest(req)
|
||||
_, err := m.makeRequest(req, true)
|
||||
if err != nil {
|
||||
// Clean up mount point on failure
|
||||
m.forceUnmountPath(mountPath)
|
||||
@@ -218,7 +218,7 @@ func (m *Manager) unmount(provider string) error {
|
||||
|
||||
var rcErr error
|
||||
if m.IsReady() {
|
||||
_, rcErr = m.makeRequest(req)
|
||||
_, rcErr = m.makeRequest(req, true)
|
||||
}
|
||||
|
||||
// If RC unmount fails or server is not ready, try force unmount
|
||||
@@ -335,7 +335,7 @@ func (m *Manager) RefreshDir(provider string, dirs []string) error {
|
||||
Args: args,
|
||||
}
|
||||
|
||||
_, err := m.makeRequest(req)
|
||||
_, err := m.makeRequest(req, true)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).
|
||||
Str("provider", provider).
|
||||
@@ -348,7 +348,7 @@ func (m *Manager) RefreshDir(provider string, dirs []string) error {
|
||||
Args: args,
|
||||
}
|
||||
|
||||
_, err = m.makeRequest(req)
|
||||
_, err = m.makeRequest(req, true)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).
|
||||
Str("provider", provider).
|
||||
@@ -373,16 +373,10 @@ func (m *Manager) createConfig(configName, webdavURL string) error {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := m.makeRequest(req)
|
||||
_, err := m.makeRequest(req, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config %s: %w", configName, err)
|
||||
}
|
||||
|
||||
m.logger.Trace().
|
||||
Str("config_name", configName).
|
||||
Str("webdav_url", webdavURL).
|
||||
Msg("Rclone config created")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func (m *Manager) checkMountHealth(provider string) bool {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := m.makeRequest(req)
|
||||
_, err := m.makeRequest(req, true)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
|
||||
@@ -56,11 +56,6 @@ type RCResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type CoreStatsResponse struct {
|
||||
TransferStats map[string]interface{} `json:"transferStats"`
|
||||
CoreStats map[string]interface{} `json:"coreStats"`
|
||||
}
|
||||
|
||||
// NewManager creates a new rclone RC manager
|
||||
func NewManager() *Manager {
|
||||
cfg := config.Get()
|
||||
@@ -305,12 +300,11 @@ func (m *Manager) waitForServer() {
|
||||
// pingServer checks if the RC server is responding
|
||||
func (m *Manager) pingServer() bool {
|
||||
req := RCRequest{Command: "core/version"}
|
||||
_, err := m.makeRequest(req)
|
||||
_, err := m.makeRequest(req, true)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// makeRequest makes a request to the rclone RC server
|
||||
func (m *Manager) makeRequest(req RCRequest) (*RCResponse, error) {
|
||||
func (m *Manager) makeRequest(req RCRequest, close bool) (*http.Response, error) {
|
||||
reqBody, err := json.Marshal(req.Args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
@@ -328,26 +322,30 @@ func (m *Manager) makeRequest(req RCRequest) (*RCResponse, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
m.logger.Debug().Err(err).Msg("Failed to close response body")
|
||||
}
|
||||
}()
|
||||
|
||||
var rcResp RCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rcResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if rcResp.Error != "" {
|
||||
return nil, fmt.Errorf("rclone error: %s", rcResp.Error)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d - %s", resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||
// Read the response body to get more details
|
||||
defer resp.Body.Close()
|
||||
var errorResp RCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errorResp); err != nil {
|
||||
return nil, fmt.Errorf("request failed with status %s, but could not decode error response: %w", resp.Status, err)
|
||||
}
|
||||
if errorResp.Error != "" {
|
||||
return nil, fmt.Errorf("%s", errorResp.Error)
|
||||
} else {
|
||||
return nil, fmt.Errorf("request failed with status %s and no error message", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
return &rcResp, nil
|
||||
if close {
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
m.logger.Debug().Err(err).Msg("Failed to close response body")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// IsReady returns true if the RC server is ready
|
||||
|
||||
@@ -5,49 +5,118 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type TransferringStat struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
ETA int64 `json:"eta"`
|
||||
Name string `json:"name"`
|
||||
Speed float64 `json:"speed"`
|
||||
Size int64 `json:"size"`
|
||||
Progress float64 `json:"progress"`
|
||||
}
|
||||
|
||||
type VersionResponse struct {
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
type CoreStatsResponse struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
Checks int `json:"checks"`
|
||||
DeletedDirs int `json:"deletedDirs"`
|
||||
Deletes int `json:"deletes"`
|
||||
ElapsedTime float64 `json:"elapsedTime"`
|
||||
Errors int `json:"errors"`
|
||||
Eta int `json:"eta"`
|
||||
Speed float64 `json:"speed"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
TotalChecks int `json:"totalChecks"`
|
||||
TotalTransfers int `json:"totalTransfers"`
|
||||
TransferTime float64 `json:"transferTime"`
|
||||
Transfers int `json:"transfers"`
|
||||
Transferring []TransferringStat `json:"transferring,omitempty"`
|
||||
}
|
||||
|
||||
type MemoryStats struct {
|
||||
Sys int `json:"Sys"`
|
||||
TotalAlloc int64 `json:"TotalAlloc"`
|
||||
}
|
||||
|
||||
type BandwidthStats struct {
|
||||
BytesPerSecond int64 `json:"bytesPerSecond"`
|
||||
Rate string `json:"rate"`
|
||||
}
|
||||
|
||||
// Stats represents rclone statistics
|
||||
type Stats struct {
|
||||
CoreStats map[string]interface{} `json:"coreStats"`
|
||||
TransferStats map[string]interface{} `json:"transferStats"`
|
||||
MountStats map[string]*MountInfo `json:"mountStats"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Ready bool `json:"server_ready"`
|
||||
Core CoreStatsResponse `json:"core"`
|
||||
Memory MemoryStats `json:"memory"`
|
||||
Mount map[string]*MountInfo `json:"mount"`
|
||||
Bandwidth BandwidthStats `json:"bandwidth"`
|
||||
Version VersionResponse `json:"version"`
|
||||
}
|
||||
|
||||
// GetStats retrieves statistics from the rclone RC server
|
||||
func (m *Manager) GetStats() (*Stats, error) {
|
||||
stats := &Stats{}
|
||||
stats.Ready = m.IsReady()
|
||||
stats.Enabled = true
|
||||
|
||||
coreStats, err := m.GetCoreStats()
|
||||
if err == nil {
|
||||
stats.Core = *coreStats
|
||||
}
|
||||
|
||||
// Get memory usage
|
||||
memStats, err := m.GetMemoryUsage()
|
||||
if err == nil {
|
||||
stats.Memory = *memStats
|
||||
}
|
||||
// Get bandwidth stats
|
||||
bwStats, err := m.GetBandwidthStats()
|
||||
if err == nil {
|
||||
stats.Bandwidth = *bwStats
|
||||
} else {
|
||||
fmt.Println("Failed to get rclone stats", err)
|
||||
}
|
||||
|
||||
// Get version info
|
||||
versionResp, err := m.GetVersion()
|
||||
if err == nil {
|
||||
stats.Version = *versionResp
|
||||
}
|
||||
|
||||
// Get mount info
|
||||
stats.Mount = m.GetAllMounts()
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetCoreStats() (*CoreStatsResponse, error) {
|
||||
if !m.IsReady() {
|
||||
return nil, fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
|
||||
// Get core stats
|
||||
req := RCRequest{
|
||||
Command: "core/stats",
|
||||
}
|
||||
|
||||
resp, err := m.makeRequest(req)
|
||||
resp, err := m.makeRequest(req, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get rclone stats: %w", err)
|
||||
return nil, fmt.Errorf("failed to get core stats: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse the response
|
||||
var coreStatsResp CoreStatsResponse
|
||||
if respBytes, err := json.Marshal(resp.Result); err == nil {
|
||||
json.Unmarshal(respBytes, &coreStatsResp)
|
||||
var coreStats CoreStatsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&coreStats); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode core stats response: %w", err)
|
||||
}
|
||||
|
||||
// Get mount stats
|
||||
mountStats := m.GetAllMounts()
|
||||
|
||||
stats := &Stats{
|
||||
CoreStats: coreStatsResp.CoreStats,
|
||||
TransferStats: coreStatsResp.TransferStats,
|
||||
MountStats: mountStats,
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
return &coreStats, nil
|
||||
}
|
||||
|
||||
// GetMemoryUsage returns memory usage statistics
|
||||
func (m *Manager) GetMemoryUsage() (map[string]interface{}, error) {
|
||||
func (m *Manager) GetMemoryUsage() (*MemoryStats, error) {
|
||||
if !m.IsReady() {
|
||||
return nil, fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
@@ -56,20 +125,21 @@ func (m *Manager) GetMemoryUsage() (map[string]interface{}, error) {
|
||||
Command: "core/memstats",
|
||||
}
|
||||
|
||||
resp, err := m.makeRequest(req)
|
||||
resp, err := m.makeRequest(req, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get memory stats: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var memStats MemoryStats
|
||||
|
||||
if memStats, ok := resp.Result.(map[string]interface{}); ok {
|
||||
return memStats, nil
|
||||
if err := json.NewDecoder(resp.Body).Decode(&memStats); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode memory stats response: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid memory stats response")
|
||||
return &memStats, nil
|
||||
}
|
||||
|
||||
// GetBandwidthStats returns bandwidth usage for all transfers
|
||||
func (m *Manager) GetBandwidthStats() (map[string]interface{}, error) {
|
||||
func (m *Manager) GetBandwidthStats() (*BandwidthStats, error) {
|
||||
if !m.IsReady() {
|
||||
return nil, fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
@@ -78,21 +148,21 @@ func (m *Manager) GetBandwidthStats() (map[string]interface{}, error) {
|
||||
Command: "core/bwlimit",
|
||||
}
|
||||
|
||||
resp, err := m.makeRequest(req)
|
||||
resp, err := m.makeRequest(req, false)
|
||||
if err != nil {
|
||||
// Bandwidth stats might not be available, return empty
|
||||
return map[string]interface{}{}, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if bwStats, ok := resp.Result.(map[string]interface{}); ok {
|
||||
return bwStats, nil
|
||||
defer resp.Body.Close()
|
||||
var bwStats BandwidthStats
|
||||
if err := json.NewDecoder(resp.Body).Decode(&bwStats); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode bandwidth stats response: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{}, nil
|
||||
return &bwStats, nil
|
||||
}
|
||||
|
||||
// GetVersion returns rclone version information
|
||||
func (m *Manager) GetVersion() (map[string]interface{}, error) {
|
||||
func (m *Manager) GetVersion() (*VersionResponse, error) {
|
||||
if !m.IsReady() {
|
||||
return nil, fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
@@ -101,36 +171,14 @@ func (m *Manager) GetVersion() (map[string]interface{}, error) {
|
||||
Command: "core/version",
|
||||
}
|
||||
|
||||
resp, err := m.makeRequest(req)
|
||||
resp, err := m.makeRequest(req, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get version: %w", err)
|
||||
}
|
||||
|
||||
if version, ok := resp.Result.(map[string]interface{}); ok {
|
||||
return version, nil
|
||||
defer resp.Body.Close()
|
||||
var versionResp VersionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&versionResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode version response: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid version response")
|
||||
}
|
||||
|
||||
// GetConfigDump returns the current rclone configuration
|
||||
func (m *Manager) GetConfigDump() (map[string]interface{}, error) {
|
||||
if !m.IsReady() {
|
||||
return nil, fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
|
||||
req := RCRequest{
|
||||
Command: "config/dump",
|
||||
}
|
||||
|
||||
resp, err := m.makeRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config dump: %w", err)
|
||||
}
|
||||
|
||||
if config, ok := resp.Result.(map[string]interface{}); ok {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid config dump response")
|
||||
return &versionResp, nil
|
||||
}
|
||||
|
||||
@@ -121,35 +121,15 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Add rclone stats if available
|
||||
if rcManager := store.Get().RcloneManager(); rcManager != nil && rcManager.IsReady() {
|
||||
if rcStats, err := rcManager.GetStats(); err == nil {
|
||||
stats["rclone"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"server_ready": rcManager.IsReady(),
|
||||
"core_stats": rcStats.CoreStats,
|
||||
"transfer_stats": rcStats.TransferStats,
|
||||
"mount_stats": rcStats.MountStats,
|
||||
}
|
||||
|
||||
// Add memory usage
|
||||
if memStats, err := rcManager.GetMemoryUsage(); err == nil {
|
||||
stats["rclone"].(map[string]interface{})["memory_stats"] = memStats
|
||||
}
|
||||
|
||||
// Add version info
|
||||
if version, err := rcManager.GetVersion(); err == nil {
|
||||
stats["rclone"].(map[string]interface{})["version"] = version
|
||||
}
|
||||
|
||||
// Add bandwidth stats
|
||||
if bwStats, err := rcManager.GetBandwidthStats(); err == nil {
|
||||
stats["rclone"].(map[string]interface{})["bandwidth_stats"] = bwStats
|
||||
}
|
||||
} else {
|
||||
rcStats, err := rcManager.GetStats()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("Failed to get rclone stats")
|
||||
stats["rclone"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"server_ready": rcManager.IsReady(),
|
||||
"error": err.Error(),
|
||||
"server_ready": false,
|
||||
}
|
||||
} else {
|
||||
stats["rclone"] = rcStats
|
||||
}
|
||||
} else {
|
||||
stats["rclone"] = map[string]interface{}{
|
||||
|
||||
122
pkg/web/api.go
122
pkg/web/api.go
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -214,7 +215,26 @@ func (wb *Web) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
for _, a := range unique {
|
||||
cfg.Arrs = append(cfg.Arrs, a)
|
||||
}
|
||||
request.JSONResponse(w, cfg, http.StatusOK)
|
||||
|
||||
// Create response with API token info
|
||||
type ConfigResponse struct {
|
||||
*config.Config
|
||||
APIToken string `json:"api_token,omitempty"`
|
||||
AuthUsername string `json:"auth_username,omitempty"`
|
||||
}
|
||||
|
||||
response := &ConfigResponse{Config: cfg}
|
||||
|
||||
// Add API token and auth information
|
||||
auth := cfg.GetAuth()
|
||||
if auth != nil {
|
||||
if auth.APIToken != "" {
|
||||
response.APIToken = auth.APIToken
|
||||
}
|
||||
response.AuthUsername = auth.Username
|
||||
}
|
||||
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -360,3 +380,103 @@ func (wb *Web) handleStopRepairJob(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (wb *Web) handleRefreshAPIToken(w http.ResponseWriter, _ *http.Request) {
|
||||
token, err := wb.refreshAPIToken()
|
||||
if err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to refresh API token")
|
||||
http.Error(w, "Failed to refresh token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
request.JSONResponse(w, map[string]interface{}{
|
||||
"token": token,
|
||||
"message": "API token refreshed successfully",
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
func (wb *Web) handleUpdateAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ConfirmPassword string `json:"confirm_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
auth := cfg.GetAuth()
|
||||
if auth == nil {
|
||||
auth = &config.Auth{}
|
||||
}
|
||||
|
||||
// Check if trying to disable authentication (both empty)
|
||||
if req.Username == "" && req.Password == "" {
|
||||
// Disable authentication
|
||||
cfg.UseAuth = false
|
||||
auth.Username = ""
|
||||
auth.Password = ""
|
||||
if err := cfg.SaveAuth(auth); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save auth config")
|
||||
http.Error(w, "Failed to save authentication settings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save config")
|
||||
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
request.JSONResponse(w, map[string]string{
|
||||
"message": "Authentication disabled successfully",
|
||||
}, http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Username == "" {
|
||||
http.Error(w, "Username is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Password == "" {
|
||||
http.Error(w, "Password is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Password != req.ConfirmPassword {
|
||||
http.Error(w, "Passwords do not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, "Failed to process password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update auth settings
|
||||
auth.Username = req.Username
|
||||
auth.Password = string(hashedPassword)
|
||||
cfg.UseAuth = true
|
||||
|
||||
// Save auth config
|
||||
if err := cfg.SaveAuth(auth); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save auth config")
|
||||
http.Error(w, "Failed to save authentication settings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save main config
|
||||
if err := cfg.Save(); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save config")
|
||||
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
request.JSONResponse(w, map[string]string{
|
||||
"message": "Authentication settings updated successfully",
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -87,6 +87,9 @@ class ConfigManager {
|
||||
|
||||
// Load rclone config
|
||||
this.populateRcloneSettings(config.rclone);
|
||||
|
||||
// Load API token info
|
||||
this.populateAPIToken(config);
|
||||
}
|
||||
|
||||
populateGeneralSettings(config) {
|
||||
@@ -294,7 +297,7 @@ class ConfigManager {
|
||||
</label>
|
||||
<input type="text" class="input input-bordered"
|
||||
name="debrid[${index}].folder" id="debrid[${index}].folder"
|
||||
placeholder="/mnt/remote/realdebrid" required>
|
||||
placeholder="/mnt/remote/realdebrid/__all__" required>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Path where debrid files are mounted</span>
|
||||
</div>
|
||||
@@ -326,7 +329,6 @@ class ConfigManager {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options Grid - Full Width Below -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-6">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
@@ -373,14 +375,12 @@ class ConfigManager {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebDAV Configuration (Initially Hidden) -->
|
||||
<div class="webdav-section hidden mt-6" id="webdav-section-${index}">
|
||||
<div class="divider">
|
||||
<span class="text-lg font-semibold">WebDAV Settings</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<!-- WebDAV Basic Settings -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="debrid[${index}].torrents_refresh_interval">
|
||||
<span class="label-text font-medium">Torrents Refresh Interval</span>
|
||||
@@ -506,7 +506,6 @@ class ConfigManager {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Directories -->
|
||||
<div class="mt-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h4 class="text-lg font-semibold">Virtual Directories</h4>
|
||||
@@ -516,7 +515,6 @@ class ConfigManager {
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70 mb-4">Create virtual directories with filters to organize your content</p>
|
||||
<div class="directories-container space-y-4" id="debrid[${index}].directories">
|
||||
<!-- Dynamic directories will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -601,7 +599,6 @@ class ConfigManager {
|
||||
</div>
|
||||
|
||||
<div class="filters-container space-y-2" id="debrid[${debridIndex}].directory[${dirIndex}].filters">
|
||||
<!-- Filters will be added here -->
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -1281,6 +1278,19 @@ class ConfigManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
populateAPIToken(config) {
|
||||
const tokenDisplay = document.getElementById('api-token-display');
|
||||
if (tokenDisplay) {
|
||||
tokenDisplay.value = config.api_token || '****';
|
||||
}
|
||||
|
||||
// Populate username (password is not populated for security)
|
||||
const usernameField = document.getElementById('auth-username');
|
||||
if (usernameField && config.auth_username) {
|
||||
usernameField.value = config.auth_username;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (wb *Web) verifyAuth(username, password string) bool {
|
||||
@@ -32,3 +36,68 @@ func (wb *Web) skipAuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// isValidAPIToken checks if the request contains a valid API token
|
||||
func (wb *Web) isValidAPIToken(r *http.Request) bool {
|
||||
// Check Authorization header for Bearer token
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Support both "Bearer <token>" and "Token <token>" formats
|
||||
var token string
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
} else if strings.HasPrefix(authHeader, "Token ") {
|
||||
token = strings.TrimPrefix(authHeader, "Token ")
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get auth config and check if token exists
|
||||
auth := config.Get().GetAuth()
|
||||
if auth == nil || auth.APIToken == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the provided token matches the configured token
|
||||
return token == auth.APIToken
|
||||
}
|
||||
|
||||
// generateAPIToken creates a new random API token
|
||||
func (wb *Web) generateAPIToken() (string, error) {
|
||||
bytes := make([]byte, 32) // 256-bit token
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// refreshAPIToken generates a new API token and saves it
|
||||
func (wb *Web) refreshAPIToken() (string, error) {
|
||||
auth := config.Get().GetAuth()
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("authentication not configured")
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
token, err := wb.generateAPIToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Update auth config
|
||||
auth.APIToken = token
|
||||
|
||||
// Save auth config
|
||||
if err := config.Get().SaveAuth(auth); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (wb *Web) setupMiddleware(next http.Handler) http.Handler {
|
||||
@@ -33,19 +35,51 @@ func (wb *Web) authMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
isAPI := wb.isAPIRequest(r)
|
||||
|
||||
if cfg.NeedsAuth() {
|
||||
http.Redirect(w, r, "/register", http.StatusSeeOther)
|
||||
if isAPI {
|
||||
wb.sendJSONError(w, "Authentication setup required", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Redirect(w, r, "/register", http.StatusSeeOther)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for API token first
|
||||
if wb.isValidAPIToken(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to session authentication
|
||||
session, _ := wb.cookie.Get(r, "auth-session")
|
||||
auth, ok := session.Values["authenticated"].(bool)
|
||||
|
||||
if !ok || !auth {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
if isAPI {
|
||||
wb.sendJSONError(w, "Authentication required. Please provide a valid API token in the Authorization header (Bearer <token>) or authenticate via session cookies.", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// isAPIRequest checks if the request is for an API endpoint
|
||||
func (wb *Web) isAPIRequest(r *http.Request) bool {
|
||||
return strings.HasPrefix(r.URL.Path, "/api/")
|
||||
}
|
||||
|
||||
// sendJSONError sends a JSON error response
|
||||
func (wb *Web) sendJSONError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": message,
|
||||
"status": statusCode,
|
||||
})
|
||||
}
|
||||
@@ -50,6 +50,8 @@ func (wb *Web) Routes() http.Handler {
|
||||
r.Delete("/torrents/", wb.handleDeleteTorrents)
|
||||
r.Get("/config", wb.handleGetConfig)
|
||||
r.Post("/config", wb.handleUpdateConfig)
|
||||
r.Post("/refresh-token", wb.handleRefreshAPIToken)
|
||||
r.Post("/update-auth", wb.handleUpdateAuth)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
{{ define "config" }}
|
||||
<div class="space-y-6">
|
||||
<!-- Configuration Form -->
|
||||
<form id="configForm" class="space-y-6">
|
||||
<!-- Tab Navigation Card -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<!-- Modern Tab Navigation -->
|
||||
<div class="border-b border-base-300 mb-8">
|
||||
<nav class="flex space-x-8" aria-label="Configuration Tabs">
|
||||
<button type="button" class="tab-button active flex items-center gap-2 py-3 px-1 border-b-2 border-primary text-primary font-medium text-sm" data-tab="general">
|
||||
@@ -35,17 +32,14 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Save Button (Sticky) -->
|
||||
<div class="sticky top-20 z-30 flex justify-end mb-6">
|
||||
<button type="submit" class="btn btn-success btn-lg shadow-lg">
|
||||
<i class="bi bi-save mr-2"></i>Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content Container -->
|
||||
<div class="tab-content-container">
|
||||
|
||||
<!-- General Tab Content -->
|
||||
<div class="tab-content" data-tab-content="general">
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-2xl font-bold flex items-center mb-6">
|
||||
@@ -157,10 +151,123 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Settings Section -->
|
||||
<div class="divider">
|
||||
<span class="text-lg font-semibold">Authentication Settings</span>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold mb-2">Authentication Settings</h3>
|
||||
<p class="text-sm text-base-content/70">Configure username/password authentication and API token for programmatic access.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username/Password Section -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold text-base">Web Authentication</h4>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Username</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
id="auth-username"
|
||||
name="auth_username"
|
||||
class="input input-bordered"
|
||||
placeholder="Enter username (leave empty to disable auth)">
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Leave empty to disable authentication</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Password</span>
|
||||
</label>
|
||||
<div class="password-toggle-container">
|
||||
<input type="password"
|
||||
id="auth-password"
|
||||
name="auth_password"
|
||||
class="input input-bordered input-has-toggle"
|
||||
placeholder="Enter password">
|
||||
<button type="button" class="password-toggle-btn">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Leave empty to disable authentication</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Confirm Password</span>
|
||||
</label>
|
||||
<div class="password-toggle-container">
|
||||
<input type="password"
|
||||
id="auth-password-confirm"
|
||||
name="auth_password_confirm"
|
||||
class="input input-bordered input-has-toggle"
|
||||
placeholder="Confirm password">
|
||||
<button type="button" class="password-toggle-btn">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt" id="password-match-indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Current Token</span>
|
||||
</label>
|
||||
<div class="join">
|
||||
<input type="text"
|
||||
id="api-token-display"
|
||||
class="input input-bordered join-item flex-1 font-mono"
|
||||
placeholder="No token generated"
|
||||
readonly>
|
||||
<button type="button"
|
||||
id="copy-token-btn"
|
||||
class="btn btn-outline join-item"
|
||||
onclick="copyAPIToken();">
|
||||
<i class="bi bi-copy"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
id="refresh-token-btn"
|
||||
class="btn btn-outline btn-secondary join-item"
|
||||
onclick="refreshAPIToken();">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Click refresh to generate or update your API token</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end items-center mt-5">
|
||||
<button type="button"
|
||||
id="update-auth-btn"
|
||||
class="btn btn-primary"
|
||||
onclick="updateAuthSettings();">
|
||||
<i class="bi bi-shield-check mr-2"></i>Update Authentication
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-base-content/70">Use this token for API authentication instead of session cookies. Perfect for automation and scripts.</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debrid Tab Content -->
|
||||
<div class="tab-content hidden" data-tab-content="debrid">
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -173,12 +280,10 @@
|
||||
</div>
|
||||
|
||||
<div id="debridConfigs" class="space-y-4">
|
||||
<!-- Dynamic debrid configurations will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QBittorrent Tab Content -->
|
||||
<div class="tab-content hidden" data-tab-content="qbittorrent">
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-2xl font-bold flex items-center mb-6">
|
||||
@@ -226,7 +331,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrs Tab Content -->
|
||||
<div class="tab-content hidden" data-tab-content="arrs">
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -239,12 +343,10 @@
|
||||
</div>
|
||||
|
||||
<div id="arrConfigs" class="space-y-4">
|
||||
<!-- Dynamic arr configurations will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repair Tab Content -->
|
||||
<div class="tab-content hidden" data-tab-content="repair">
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-2xl font-bold flex items-center mb-6">
|
||||
@@ -332,7 +434,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rclone Tab Content -->
|
||||
<div class="tab-content hidden" data-tab-content="rclone">
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-2xl font-bold flex items-center mb-6">
|
||||
@@ -349,7 +450,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Mount Path Section -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center">
|
||||
@@ -519,7 +619,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Settings Section -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center">
|
||||
@@ -551,13 +650,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- End tab-content-container -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loadingOverlay" class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 hidden">
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="card bg-base-100 shadow-2xl">
|
||||
@@ -634,5 +732,124 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Password confirmation validation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const password = document.getElementById('auth-password');
|
||||
const confirmPassword = document.getElementById('auth-password-confirm');
|
||||
const indicator = document.getElementById('password-match-indicator');
|
||||
|
||||
function validatePasswords() {
|
||||
if (!password.value && !confirmPassword.value) {
|
||||
indicator.textContent = '';
|
||||
indicator.className = 'label-text-alt';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.value === confirmPassword.value) {
|
||||
indicator.textContent = '✓ Passwords match';
|
||||
indicator.className = 'label-text-alt text-success';
|
||||
} else {
|
||||
indicator.textContent = '✗ Passwords do not match';
|
||||
indicator.className = 'label-text-alt text-error';
|
||||
}
|
||||
}
|
||||
|
||||
password?.addEventListener('input', validatePasswords);
|
||||
confirmPassword?.addEventListener('input', validatePasswords);
|
||||
});
|
||||
|
||||
// API Token Management Functions
|
||||
async function refreshAPIToken() {
|
||||
const refreshBtn = document.getElementById('refresh-token-btn');
|
||||
const tokenDisplay = document.getElementById('api-token-display');
|
||||
|
||||
// Show loading state
|
||||
window.decypharrUtils.setButtonLoading(refreshBtn, true, 'Refresh Token');
|
||||
|
||||
try {
|
||||
const response = await window.decypharrUtils.fetcher('/api/refresh-token', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to refresh token');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
tokenDisplay.value = data.token;
|
||||
window.decypharrUtils.createToast(data.message || 'Token refreshed successfully', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing token:', error);
|
||||
window.decypharrUtils.createToast('Failed to refresh token: ' + error.message, 'error');
|
||||
} finally {
|
||||
window.decypharrUtils.setButtonLoading(refreshBtn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAPIToken() {
|
||||
const tokenDisplay = document.getElementById('api-token-display');
|
||||
const token = tokenDisplay.value;
|
||||
|
||||
if (!token || token === 'No token generated') {
|
||||
window.decypharrUtils.createToast('No token to copy. Please refresh the token first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.decypharrUtils.copyToClipboard(token);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy token:', error);
|
||||
window.decypharrUtils.createToast('Failed to copy token to clipboard', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAuthSettings() {
|
||||
const username = document.getElementById('auth-username').value;
|
||||
const password = document.getElementById('auth-password').value;
|
||||
const confirmPassword = document.getElementById('auth-password-confirm').value;
|
||||
const updateBtn = document.getElementById('update-auth-btn');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
window.decypharrUtils.createToast('Passwords do not match', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
window.decypharrUtils.setButtonLoading(updateBtn, true, 'Update Authentication');
|
||||
|
||||
try {
|
||||
const response = await window.decypharrUtils.fetcher('/api/update-auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
confirm_password: confirmPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Failed to update authentication settings');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
window.decypharrUtils.createToast(data.message, 'success');
|
||||
|
||||
// Clear password fields for security
|
||||
document.getElementById('auth-password').value = '';
|
||||
document.getElementById('auth-password-confirm').value = '';
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating auth settings:', error);
|
||||
window.decypharrUtils.createToast('Failed to update authentication: ' + error.message, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
window.decypharrUtils.setButtonLoading(updateBtn, false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
@@ -1,10 +1,8 @@
|
||||
{{ define "download" }}
|
||||
<div class="space-y-6">
|
||||
<!-- Download Form -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<form id="downloadForm" enctype="multipart/form-data" class="space-y-3">
|
||||
<!-- Torrent Input Section -->
|
||||
<div class="space-y-2">
|
||||
<div class="form-control">
|
||||
<label class="label" for="magnetURI">
|
||||
@@ -44,7 +42,6 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Configuration Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold flex items-center">
|
||||
@@ -128,7 +125,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="form-control">
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="submitDownload">
|
||||
<i class="bi bi-cloud-upload mr-2"></i>Add to Download Queue
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{{ define "index" }}
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Controls Section -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
||||
<!-- Batch Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-secondary btn-sm hidden" id="batchDeleteBtn">
|
||||
<i class="bi bi-trash"></i>
|
||||
@@ -21,7 +19,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-2 w-full lg:w-auto">
|
||||
<select class="select select-bordered select-sm w-full sm:w-auto min-w-32" id="stateFilter">
|
||||
<option value="">All States</option>
|
||||
@@ -47,7 +44,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Torrents Table -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
@@ -87,12 +83,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="torrentsList">
|
||||
<!-- Dynamic content will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center p-6 border-t border-base-200 gap-4">
|
||||
<div class="text-sm text-base-content/70">
|
||||
<span id="paginationInfo">Loading torrents...</span>
|
||||
@@ -102,7 +96,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="card bg-base-100 shadow-xl hidden" id="emptyState">
|
||||
<div class="card-body text-center py-16">
|
||||
<div class="text-6xl text-base-content/30 mb-4">
|
||||
@@ -117,7 +110,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<ul class="menu bg-base-100 shadow-lg rounded-box context-menu hidden fixed z-50" id="torrentContextMenu">
|
||||
<li class="menu-title">
|
||||
<span class="torrent-name text-sm font-bold truncate max-w-48"></span>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{.URLBase}}images/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="{{.URLBase}}images/favicon/site.webmanifest">
|
||||
|
||||
<!-- Preload JavaScript -->
|
||||
<link rel="preload" href="{{.URLBase}}assets/js/common.js" as="script">
|
||||
|
||||
<script>
|
||||
@@ -34,11 +33,9 @@
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 flex flex-col">
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container fixed bottom-4 right-4 z-50 space-y-2">
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<header class="navbar bg-base-100 shadow-lg sticky top-0 z-40 backdrop-blur-sm">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
@@ -72,9 +69,8 @@
|
||||
</ul>
|
||||
</div>
|
||||
<a class="btn btn-ghost text-xl font-bold text-primary group" href="{{.URLBase}}">
|
||||
<!-- Logo -->
|
||||
<img src="{{.URLBase}}images/logo.svg" alt="Decypharr Logo" class="w-8 h-8 inline-block mr-2">
|
||||
<span class="hidden sm:inline bg-clip-text text-transparent">Decypharr</span>
|
||||
<span class="hidden sm:inline bg-clip-text">Decypharr</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -100,10 +96,6 @@
|
||||
<i class="bi bi-cloud"></i>
|
||||
<span class="hidden xl:inline">WebDAV</span>
|
||||
</a></li>
|
||||
<li><a href="{{.URLBase}}stats" class="{{if eq .Page "stats"}}active{{end}} tooltip tooltip-bottom" data-tip="System Statistics">
|
||||
<i class="bi bi-graph-up"></i>
|
||||
<span class="hidden xl:inline">Stats</span>
|
||||
</a></li>
|
||||
<li><a href="{{.URLBase}}logs" target="_blank" class="tooltip tooltip-bottom" data-tip="System Logs">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<span class="hidden xl:inline">Logs</span>
|
||||
@@ -113,26 +105,21 @@
|
||||
|
||||
<div class="navbar-end">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Theme Toggle -->
|
||||
<div class="tooltip tooltip-left">
|
||||
<label class="swap swap-rotate btn btn-ghost btn-circle hover:bg-base-300 transition-colors">
|
||||
<input type="checkbox" id="themeToggle" class="theme-controller" />
|
||||
<!-- Sun icon for light mode -->
|
||||
<i class="swap-off bi bi-sun text-lg text-warning"></i>
|
||||
<!-- Moon icon for dark mode -->
|
||||
<i class="swap-on bi bi-moon-stars text-lg text-info"></i>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Stats Link -->
|
||||
<div class="tooltip tooltip-left" data-tip="System Statistics">
|
||||
<a href="{{.URLBase}}debug/stats" class="btn btn-ghost btn-sm hover:bg-base-300 transition-colors">
|
||||
<a href="{{.URLBase}}stats" class="btn btn-ghost btn-sm hover:bg-base-300 transition-colors">
|
||||
<i class="bi bi-graph-up text-lg"></i>
|
||||
<span class="hidden md:inline ml-1">Stats</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Version Badge -->
|
||||
<div class="tooltip tooltip-left" data-tip="Current Version">
|
||||
<div class="badge badge-primary font-mono text-xs hover:badge-primary-focus transition-colors cursor-pointer" id="version-badge">
|
||||
Loading...
|
||||
@@ -142,7 +129,6 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 container mx-auto px-4 py-6">
|
||||
{{ if eq .Page "index" }}
|
||||
{{ template "index" . }}
|
||||
@@ -171,7 +157,6 @@
|
||||
{{ end }}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer footer-center p-6 bg-base-300 text-base-content border-t border-base-200">
|
||||
<aside class="grid-flow-col gap-4">
|
||||
<a href="https://github.com/sirrobot01/decypharr" target="_blank"
|
||||
@@ -187,11 +172,9 @@
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="{{.URLBase}}assets/js/jquery-3.7.1.min.js"></script>
|
||||
<script src="{{.URLBase}}assets/js/common.js"></script>
|
||||
|
||||
<!-- Page-specific scripts -->
|
||||
{{ if eq .Page "index" }}
|
||||
<script src="{{.URLBase}}assets/js/dashboard.js"></script>
|
||||
{{ else if eq .Page "download" }}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{{ define "repair" }}
|
||||
<div class="space-y-6">
|
||||
<!-- Repair Form -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-6">
|
||||
@@ -9,7 +8,6 @@
|
||||
|
||||
<form id="repairForm" class="space-y-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="arrSelect">
|
||||
@@ -44,7 +42,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -89,7 +86,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-header">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 p-6 border-b border-base-200">
|
||||
@@ -136,19 +132,15 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobsTableBody">
|
||||
<!-- Dynamic content will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex justify-center p-6 border-t border-base-200">
|
||||
<div class="join" id="jobsPagination">
|
||||
<!-- Pagination will be generated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Jobs Message -->
|
||||
<div id="noJobsMessage" class="text-center py-16 hidden">
|
||||
<div class="text-6xl text-base-content/30 mb-4">
|
||||
<i class="bi bi-clipboard-check"></i>
|
||||
@@ -160,7 +152,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Modal -->
|
||||
<dialog id="jobDetailsModal" class="modal">
|
||||
<div class="modal-box max-w-6xl max-h-[90vh]">
|
||||
<form method="dialog">
|
||||
@@ -174,7 +165,6 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Job Information -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
@@ -221,7 +211,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div id="errorContainer" class="alert alert-error hidden">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<div>
|
||||
@@ -230,7 +219,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Broken Items Section -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
@@ -240,7 +228,6 @@
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<div class="form-control">
|
||||
<input type="text"
|
||||
@@ -268,7 +255,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Table -->
|
||||
<div class="overflow-x-auto max-h-96 border border-base-300 rounded-lg">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="sticky top-0 bg-base-300">
|
||||
@@ -280,19 +266,15 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="brokenItemsTableBody">
|
||||
<!-- Broken items will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Items Pagination -->
|
||||
<div class="flex justify-center mt-4">
|
||||
<div class="join" id="itemsPagination">
|
||||
<!-- Pagination will be generated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Items Messages -->
|
||||
<div id="noBrokenItemsMessage" class="text-center py-8 hidden">
|
||||
<div class="text-4xl text-base-content/30 mb-2">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
|
||||
@@ -8,15 +8,12 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="loading-stats" class="text-center py-12">
|
||||
<div class="loading loading-spinner loading-lg text-primary"></div>
|
||||
<p class="mt-4 text-base-content/70">Loading system statistics...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Content -->
|
||||
<div id="stats-content" class="space-y-6" style="display: none;">
|
||||
<!-- System Overview -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-header p-6 pb-3">
|
||||
<h2 class="card-title text-xl">
|
||||
@@ -62,7 +59,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rclone Stats -->
|
||||
<div class="card bg-base-100 shadow-xl" id="debrid-card">
|
||||
<div class="card-header p-6 pb-3">
|
||||
<h2 class="card-title text-xl">
|
||||
<i class="bi bi-cloud-download text-secondary"></i>
|
||||
Debrid Services
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body p-6 pt-3" id="debrid-content">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl" id="rclone-card">
|
||||
<div class="card-header p-6 pb-3">
|
||||
<h2 class="card-title text-xl">
|
||||
@@ -72,44 +79,10 @@
|
||||
<div class="badge" id="rclone-status">Unknown</div>
|
||||
</div>
|
||||
<div class="card-body p-6 pt-3" id="rclone-content">
|
||||
<!-- Rclone stats will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debrid Services -->
|
||||
<div class="card bg-base-100 shadow-xl" id="debrid-card">
|
||||
<div class="card-header p-6 pb-3">
|
||||
<h2 class="card-title text-xl">
|
||||
<i class="bi bi-cloud-download text-secondary"></i>
|
||||
Debrid Services
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body p-6 pt-3" id="debrid-content">
|
||||
<!-- Debrid stats will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Details -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-header p-6 pb-3">
|
||||
<h2 class="card-title text-xl">
|
||||
<i class="bi bi-bar-chart text-accent"></i>
|
||||
Memory Details
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body p-6 pt-3">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<tbody id="memory-details">
|
||||
<!-- Memory details will be populated here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="error-stats" class="alert alert-error" style="display: none;">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span id="error-message">Failed to load statistics</span>
|
||||
@@ -121,232 +94,310 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const loadingEl = document.getElementById('loading-stats');
|
||||
const contentEl = document.getElementById('stats-content');
|
||||
const errorEl = document.getElementById('error-stats');
|
||||
const refreshBtn = document.getElementById('refresh-stats');
|
||||
const retryBtn = document.getElementById('retry-stats');
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const loadingEl = document.getElementById('loading-stats');
|
||||
const contentEl = document.getElementById('stats-content');
|
||||
const errorEl = document.getElementById('error-stats');
|
||||
const refreshBtn = document.getElementById('refresh-stats');
|
||||
const retryBtn = document.getElementById('retry-stats');
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatNumber(num) {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
loadingEl.style.display = 'none';
|
||||
contentEl.style.display = 'none';
|
||||
errorEl.style.display = 'flex';
|
||||
document.getElementById('error-message').textContent = message;
|
||||
}
|
||||
|
||||
function showContent() {
|
||||
loadingEl.style.display = 'none';
|
||||
errorEl.style.display = 'none';
|
||||
contentEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
contentEl.style.display = 'none';
|
||||
errorEl.style.display = 'none';
|
||||
loadingEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function updateRcloneStats(rclone) {
|
||||
const rcloneCard = document.getElementById('rclone-card');
|
||||
const rcloneStatus = document.getElementById('rclone-status');
|
||||
const rcloneContent = document.getElementById('rclone-content');
|
||||
|
||||
if (!rclone || !rclone.enabled) {
|
||||
rcloneStatus.textContent = 'Disabled';
|
||||
rcloneStatus.className = 'badge badge-error';
|
||||
rcloneContent.innerHTML = '<p class="text-base-content/70">Rclone is not enabled or configured.</p>';
|
||||
return;
|
||||
function formatNumber(num) {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
if (!rclone.server_ready) {
|
||||
rcloneStatus.textContent = 'Not Ready';
|
||||
rcloneStatus.className = 'badge badge-warning';
|
||||
rcloneContent.innerHTML = '<p class="text-base-content/70">Rclone server is not ready.</p>';
|
||||
return;
|
||||
function showError(message) {
|
||||
loadingEl.style.display = 'none';
|
||||
contentEl.style.display = 'none';
|
||||
errorEl.style.display = 'flex';
|
||||
document.getElementById('error-message').textContent = message;
|
||||
}
|
||||
|
||||
rcloneStatus.textContent = 'Active';
|
||||
rcloneStatus.className = 'badge badge-success';
|
||||
|
||||
let html = '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">';
|
||||
|
||||
if (rclone.core_stats) {
|
||||
html += `
|
||||
<div class="stat">
|
||||
<div class="stat-title">Core Version</div>
|
||||
<div class="stat-value text-sm">${rclone.version || 'Unknown'}</div>
|
||||
</div>
|
||||
`;
|
||||
function showContent() {
|
||||
loadingEl.style.display = 'none';
|
||||
errorEl.style.display = 'none';
|
||||
contentEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (rclone.transfer_stats) {
|
||||
const ts = rclone.transfer_stats;
|
||||
html += `
|
||||
<div class="stat">
|
||||
<div class="stat-title">Transferred</div>
|
||||
<div class="stat-value text-primary">${formatBytes(ts.bytes || 0)}</div>
|
||||
<div class="stat-desc">Speed: ${formatBytes(ts.speed || 0)}/s</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Transfers</div>
|
||||
<div class="stat-value text-info">${ts.transfers || 0}</div>
|
||||
<div class="stat-desc">Errors: ${ts.errors || 0}</div>
|
||||
</div>
|
||||
`;
|
||||
function showLoading() {
|
||||
contentEl.style.display = 'none';
|
||||
errorEl.style.display = 'none';
|
||||
loadingEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (rclone.memory_stats) {
|
||||
const ms = rclone.memory_stats;
|
||||
html += `
|
||||
<div class="stat">
|
||||
<div class="stat-title">Rclone Memory</div>
|
||||
<div class="stat-value text-accent">${formatBytes(ms.sys || 0)}</div>
|
||||
<div class="stat-desc">Heap: ${formatBytes(ms.heap_alloc || 0)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
function updateRcloneStats(rclone) {
|
||||
const rcloneCard = document.getElementById('rclone-card');
|
||||
const rcloneStatus = document.getElementById('rclone-status');
|
||||
const rcloneContent = document.getElementById('rclone-content');
|
||||
|
||||
html += '</div>';
|
||||
rcloneContent.innerHTML = html;
|
||||
}
|
||||
if (!rclone || !rclone.enabled) {
|
||||
rcloneStatus.textContent = 'Disabled';
|
||||
rcloneStatus.className = 'badge badge-error';
|
||||
rcloneContent.innerHTML = '<p class="text-base-content/70">Rclone is not enabled or configured.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
function updateDebridStats(debrids) {
|
||||
const debridContent = document.getElementById('debrid-content');
|
||||
if (!rclone.server_ready) {
|
||||
rcloneStatus.textContent = 'Not Ready';
|
||||
rcloneStatus.className = 'badge badge-warning';
|
||||
rcloneContent.innerHTML = '<p class="text-base-content/70">Rclone server is not ready.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!debrids || debrids.length === 0) {
|
||||
debridContent.innerHTML = '<p class="text-base-content/70">No debrid services configured.</p>';
|
||||
return;
|
||||
}
|
||||
rcloneStatus.textContent = 'Active';
|
||||
rcloneStatus.className = 'badge badge-success';
|
||||
|
||||
let html = '<div class="space-y-4">';
|
||||
debrids.forEach(debrid => {
|
||||
html += `
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="card-title text-lg">${debrid.name || 'Unknown Service'}</h3>
|
||||
<p class="text-sm text-base-content/70">${debrid.username || 'No username'}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-mono">${formatBytes(debrid.points || 0)} points</div>
|
||||
<div class="text-xs text-base-content/70">Expires: ${debrid.expiration || 'Unknown'}</div>
|
||||
let html = '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">';
|
||||
|
||||
// Version info
|
||||
if (rclone.version) {
|
||||
html += `
|
||||
<div class="stat">
|
||||
<div class="stat-title">Rclone Version</div>
|
||||
<div class="stat-value text-sm">${rclone.version.version || 'Unknown'}</div>
|
||||
<div class="stat-desc">${rclone.version.arch || ''} ${rclone.version.os || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Core stats contain transfer information
|
||||
if (rclone.core) {
|
||||
const cs = rclone.core;
|
||||
html += `
|
||||
<div class="stat">
|
||||
<div class="stat-title">Transferred</div>
|
||||
<div class="stat-value text-primary">${window.decypharrUtils.formatBytes(cs.bytes || 0)}</div>
|
||||
<div class="stat-desc">Speed: ${window.decypharrUtils.formatBytes(cs.speed || 0)}/s</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Transfers</div>
|
||||
<div class="stat-value text-info">${cs.transfers || 0}</div>
|
||||
<div class="stat-desc">Errors: ${cs.errors || 0}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Checks</div>
|
||||
<div class="stat-value text-secondary">${cs.checks || 0}</div>
|
||||
<div class="stat-desc">Total: ${cs.totalChecks || 0}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Elapsed Time</div>
|
||||
<div class="stat-value text-accent">${((cs.elapsedTime || 0) / 60).toFixed(1)}m</div>
|
||||
<div class="stat-desc">Transfer: ${((cs.transferTime || 0) / 60).toFixed(1)}m</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (rclone.memory) {
|
||||
const ms = rclone.memory;
|
||||
html += `
|
||||
<div class="stat">
|
||||
<div class="stat-title">Rclone Memory</div>
|
||||
<div class="stat-value text-accent">${window.decypharrUtils.formatBytes(ms.Sys || 0)}</div>
|
||||
<div class="stat-desc">Heap: ${window.decypharrUtils.formatBytes(ms.TotalAlloc || 0)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Add active transfers if available
|
||||
if (rclone.core && rclone.core.transferring && rclone.core.transferring.length > 0) {
|
||||
let transferring = rclone.core.transferring;
|
||||
html += `
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
<i class="bi bi-arrow-down-up text-primary"></i>
|
||||
Active Transfers (${transferring.length})
|
||||
</h3>
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
`;
|
||||
|
||||
transferring.forEach(transfer => {
|
||||
const progress = ((transfer.bytes || 0) / (transfer.size || 1)) * 100;
|
||||
html += `
|
||||
<div class="card bg-base-200 compact">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h4 class="font-medium text-sm truncate flex-1 mr-2">${transfer.name || 'Unknown'}</h4>
|
||||
<span class="text-xs text-base-content/70">${window.decypharrUtils.formatBytes(transfer.speed || 0)}/s</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<progress class="progress progress-primary flex-1" value="${progress}" max="100"></progress>
|
||||
<span class="text-xs font-mono">${progress.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-base-content/60 mt-1">
|
||||
<span>${window.decypharrUtils.formatBytes(transfer.bytes || 0)} / ${window.decypharrUtils.formatBytes(transfer.size || 0)}</span>
|
||||
<span>ETA: ${transfer.eta ? Math.ceil(transfer.eta / 60) + 'm' : 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Library Size</div>
|
||||
<div class="stat-value text-sm">${formatNumber(debrid.library_size || 0)}</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Add mount information if available
|
||||
if (rclone.mounts && Object.keys(rclone.mounts).length > 0) {
|
||||
html += `
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
<i class="bi bi-hdd-stack text-primary"></i>
|
||||
Mounted Services
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
`;
|
||||
|
||||
Object.entries(rclone.mounts).forEach(([name, mount]) => {
|
||||
const statusBadge = mount.mounted ?
|
||||
'<span class="badge badge-success badge-sm">Mounted</span>' :
|
||||
'<span class="badge badge-error badge-sm">Not Mounted</span>';
|
||||
|
||||
html += `
|
||||
<div class="card bg-base-200 compact">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h4 class="font-medium">${mount.config_name || name}</h4>
|
||||
<p class="text-xs text-base-content/70">${mount.provider || 'Unknown Provider'}</p>
|
||||
${mount.local_path ? `<p class="text-xs text-base-content/60 font-mono">${mount.local_path}</p>` : ''}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
${statusBadge}
|
||||
${mount.mounted_at ? `<p class="text-xs text-base-content/60">Since: ${new Date(mount.mounted_at).toLocaleTimeString()}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${mount.error ? `<div class="alert alert-error alert-sm mt-2"><span class="text-xs">${mount.error}</span></div>` : ''}
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Bad Torrents</div>
|
||||
<div class="stat-value text-sm text-error">${formatNumber(debrid.bad_torrents || 0)}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Add bandwidth stats if available
|
||||
if (rclone.bandwidth && rclone.bandwidth.rate !== "off") {
|
||||
html += `
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-3">
|
||||
<i class="bi bi-speedometer2 text-info"></i>
|
||||
Bandwidth Limits
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
`;
|
||||
|
||||
html += `
|
||||
<div class="stat bg-base-200 rounded-lg p-3">
|
||||
<div class="stat-title text-xs">Bytes Per Seconds</div>
|
||||
<div class="stat-value text-sm">${window.decypharrUtils.formatBytes(rclone.bandwidth.bytesPerSecond)}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg p-3">
|
||||
<div class="stat-title text-xs">Rate</div>
|
||||
<div class="stat-value text-sm">${window.decypharrUtils.formatBytes(rclone.bandwidth.rate)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
rcloneContent.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateDebridStats(debrids) {
|
||||
const debridContent = document.getElementById('debrid-content');
|
||||
|
||||
if (!debrids || debrids.length === 0) {
|
||||
debridContent.innerHTML = '<p class="text-base-content/70">No debrid services configured.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="space-y-4">';
|
||||
debrids.forEach(debrid => {
|
||||
html += `
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="card-title text-lg">${debrid.name || 'Unknown Service'}</h3>
|
||||
<p class="text-sm text-base-content/70">${debrid.username || 'No username'}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-mono">${debrid.points} points</div>
|
||||
<div class="text-xs text-base-content/70">Expires: ${debrid.expiration || 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Active Links</div>
|
||||
<div class="stat-value text-sm text-success">${formatNumber(debrid.active_links || 0)}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Type</div>
|
||||
<div class="stat-value text-sm">${debrid.type || 'Unknown'}</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Library Size</div>
|
||||
<div class="stat-value text-sm">${formatNumber(debrid.library_size || 0)}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Bad Torrents</div>
|
||||
<div class="stat-value text-sm text-error">${formatNumber(debrid.bad_torrents || 0)}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Active Links</div>
|
||||
<div class="stat-value text-sm text-success">${formatNumber(debrid.active_links || 0)}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Type</div>
|
||||
<div class="stat-value text-sm">${debrid.type || 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
debridContent.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateMemoryDetails(stats) {
|
||||
const memoryDetails = document.getElementById('memory-details');
|
||||
const details = [
|
||||
['Total Allocated', stats.total_alloc_mb || '-'],
|
||||
['Heap Allocated', stats.heap_alloc_mb || '-'],
|
||||
['System Memory', stats.memory_used || '-'],
|
||||
['GC Cycles', formatNumber(stats.gc_cycles || 0)],
|
||||
['Goroutines', formatNumber(stats.goroutines || 0)],
|
||||
['CPU Cores', stats.num_cpu || '-'],
|
||||
['OS/Arch', `${stats.os || 'Unknown'}/${stats.arch || 'Unknown'}`],
|
||||
['Go Version', stats.go_version || '-']
|
||||
];
|
||||
|
||||
let html = '';
|
||||
details.forEach(([label, value]) => {
|
||||
html += `
|
||||
<tr>
|
||||
<td class="font-medium">${label}</td>
|
||||
<td class="font-mono">${value}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
memoryDetails.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateStats(stats) {
|
||||
// System overview
|
||||
document.getElementById('memory-used').textContent = stats.memory_used || '-';
|
||||
document.getElementById('heap-alloc').textContent = `Heap: ${stats.heap_alloc_mb || '-'}`;
|
||||
document.getElementById('goroutines').textContent = formatNumber(stats.goroutines || 0);
|
||||
document.getElementById('gc-cycles').textContent = `GC: ${formatNumber(stats.gc_cycles || 0)} cycles`;
|
||||
document.getElementById('num-cpu').textContent = stats.num_cpu || '-';
|
||||
document.getElementById('arch').textContent = stats.arch || '-';
|
||||
document.getElementById('os').textContent = stats.os || '-';
|
||||
document.getElementById('go-version').textContent = stats.go_version || '-';
|
||||
|
||||
// Rclone stats
|
||||
updateRcloneStats(stats.rclone);
|
||||
|
||||
// Debrid stats
|
||||
updateDebridStats(stats.debrids);
|
||||
|
||||
// Memory details
|
||||
updateMemoryDetails(stats);
|
||||
}
|
||||
|
||||
function loadStats() {
|
||||
showLoading();
|
||||
|
||||
fetch(`${window.urlBase}debug/stats`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(stats => {
|
||||
updateStats(stats);
|
||||
showContent();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading stats:', error);
|
||||
showError(error.message || 'Failed to load statistics');
|
||||
`;
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
debridContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
refreshBtn.addEventListener('click', loadStats);
|
||||
retryBtn.addEventListener('click', loadStats);
|
||||
function updateStats(stats) {
|
||||
// System overview
|
||||
document.getElementById('memory-used').textContent = stats.memory_used || '-';
|
||||
document.getElementById('heap-alloc').textContent = `Heap: ${stats.heap_alloc_mb || '-'}`;
|
||||
document.getElementById('goroutines').textContent = formatNumber(stats.goroutines || 0);
|
||||
document.getElementById('gc-cycles').textContent = `GC: ${formatNumber(stats.gc_cycles || 0)} cycles`;
|
||||
document.getElementById('num-cpu').textContent = stats.num_cpu || '-';
|
||||
document.getElementById('arch').textContent = stats.arch || '-';
|
||||
document.getElementById('os').textContent = stats.os || '-';
|
||||
document.getElementById('go-version').textContent = stats.go_version || '-';
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(loadStats, 30000);
|
||||
// Rclone stats
|
||||
updateRcloneStats(stats.rclone);
|
||||
|
||||
// Initial load
|
||||
loadStats();
|
||||
});
|
||||
// Debrid stats
|
||||
updateDebridStats(stats.debrids);
|
||||
}
|
||||
|
||||
function loadStats() {
|
||||
showLoading();
|
||||
|
||||
fetch(`${window.urlBase}debug/stats`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(stats => {
|
||||
updateStats(stats);
|
||||
showContent();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading stats:', error);
|
||||
showError(error.message || 'Failed to load statistics');
|
||||
});
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
refreshBtn.addEventListener('click', loadStats);
|
||||
retryBtn.addEventListener('click', loadStats);
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(loadStats, 30000);
|
||||
|
||||
// Initial load
|
||||
loadStats();
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user