feat: Implement comprehensive log viewer with advanced features

- Add new log viewer component in admin tools
- Support dynamic log file browsing and content display
- Implement custom scrollbar for log content
- Add log file refresh, download, and view capabilities
- Update environment variables for log configuration
- Enhance logging system with more flexible configuration options
This commit is contained in:
StarFleetCPTN
2025-03-11 20:50:47 -07:00
parent ed81b2c9be
commit a8b4588ecb
15 changed files with 634 additions and 761 deletions
+13 -13
View File
@@ -151,12 +151,12 @@ services:
- EMAIL_USERNAME=smtp_username
- EMAIL_PASSWORD=smtp_password
# Logging configuration
- GOMFT_LOGS_DIR=/app/data/logs
- GOMFT_LOG_MAX_SIZE=10
- GOMFT_LOG_MAX_BACKUPS=5
- GOMFT_LOG_MAX_AGE=30
- GOMFT_LOG_COMPRESS=true
- GOMFT_LOG_LEVEL=info
- LOGS_DIR=/app/data/logs
- LOG_MAX_SIZE=10
- LOG_MAX_BACKUPS=5
- LOG_MAX_AGE=30
- LOG_COMPRESS=true
- LOG_LEVEL=info
```
Alternatively, you can mount your own .env file to the container:
@@ -233,12 +233,12 @@ EMAIL_PASSWORD=smtp_password
GoMFT provides configurable logging with rotation support through the following environment variables:
- `GOMFT_LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
- `GOMFT_LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`)
- `GOMFT_LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`)
- `GOMFT_LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`)
- `GOMFT_LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
- `GOMFT_LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
- `LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
- `LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`)
- `LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`)
- `LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`)
- `LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
- `LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
- `error`: Only show errors and critical issues
- `info`: Show errors and general operational information (default)
- `debug`: Show all messages including detailed debugging information
@@ -442,4 +442,4 @@ volumes:
- /host/path/backups:/app/backups # For database backups
```
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `GOMFT_LOGS_DIR`.
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
+237
View File
@@ -12,6 +12,13 @@ type BackupFile struct {
ModTime time.Time
}
type LogFile struct {
Name string
Size string
ModTime time.Time
Path string
}
type AdminToolsData struct {
JobHistoryCount int
DatabaseSize string
@@ -26,6 +33,9 @@ type AdminToolsData struct {
BackupPath string
MaintenanceMessage string
BackupFiles []BackupFile
LogFiles []LogFile
LogContent string
CurrentLogFile string
}
// Dialog component for confirmation dialogs
@@ -131,6 +141,64 @@ templ BackupActionDialog(id string, title string, message string, confirmClass s
templ AdminTools(ctx context.Context, data AdminToolsData) {
@LayoutWithContext("Admin Tools", ctx) {
<style>
/* Custom scrollbar styles - more aggressive */
.log-scrollbar {
scrollbar-width: thin !important; /* Firefox */
scrollbar-color: rgba(0,0,0,0.3) rgba(0,0,0,0.1) !important; /* Firefox */
overflow: auto !important;
}
.dark .log-scrollbar {
scrollbar-color: rgba(255,255,255,0.3) rgba(255,255,255,0.1) !important; /* Firefox */
}
.log-scrollbar::-webkit-scrollbar {
width: 10px !important;
height: 10px !important;
display: block !important;
}
.log-scrollbar::-webkit-scrollbar-track {
background: rgba(0,0,0,0.1) !important;
border-radius: 4px !important;
}
.log-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.3) !important;
border-radius: 4px !important;
border: 2px solid transparent !important;
background-clip: content-box !important;
}
.log-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.5) !important;
border: 2px solid transparent !important;
background-clip: content-box !important;
}
.dark .log-scrollbar::-webkit-scrollbar-track {
background: rgba(255,255,255,0.1) !important;
}
.dark .log-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3) !important;
border: 2px solid transparent !important;
background-clip: content-box !important;
}
.dark .log-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.5) !important;
border: 2px solid transparent !important;
background-clip: content-box !important;
}
/* Force scrollbar to appear */
.force-scroll {
overflow-y: scroll !important;
min-height: 100px !important;
}
</style>
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
@@ -448,6 +516,11 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
</div>
</div>
</div>
<!-- Log Viewer -->
<div id="logs-container" class="mt-8">
@AdminLogViewer(data)
</div>
</div>
</div>
}
@@ -576,3 +649,167 @@ templ BackupsList(data AdminToolsData) {
</div>
}
}
// Add this new template after other admin tool templates
templ AdminLogViewer(data AdminToolsData) {
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-md p-6 mb-6">
<h3 class="text-xl font-semibold mb-4 text-secondary-900 dark:text-secondary-100 flex items-center">
<i class="fas fa-file-alt mr-2"></i> Log Files
</h3>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-4">
<div class="lg:col-span-1 border-r border-secondary-200 dark:border-secondary-700 pr-4">
<h4 class="text-lg font-medium mb-2 text-secondary-900 dark:text-secondary-100">Available Logs</h4>
<div class="space-y-2 max-h-96 overflow-y-auto pr-2 log-scrollbar">
if len(data.LogFiles) == 0 {
<div class="text-secondary-600 dark:text-secondary-400 italic">
No log files found
</div>
} else {
<div class="flex flex-col space-y-1">
for _, logFile := range data.LogFiles {
<button
class={
"text-left px-3 py-2 rounded transition-colors flex justify-between items-center",
templ.KV("bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400", logFile.Name == data.CurrentLogFile),
templ.KV("hover:bg-secondary-50 dark:hover:bg-secondary-700/50 text-secondary-700 dark:text-secondary-300", logFile.Name != data.CurrentLogFile)
}
hx-get={ fmt.Sprintf("/admin/logs/view/%s", logFile.Name) }
hx-target="#log-content"
hx-indicator="#log-loading"
>
<span class="flex items-center">
<i class="fas fa-file-alt mr-2"></i>
{ logFile.Name }
</span>
<span class="text-xs text-secondary-500 dark:text-secondary-400">{ logFile.Size }</span>
</button>
}
</div>
}
</div>
<div class="mt-4 flex justify-between">
<button
class="btn-secondary btn-sm"
hx-get="/admin/logs/refresh"
hx-target="#logs-container"
hx-indicator="#refresh-logs-indicator"
>
<span id="refresh-logs-indicator" class="htmx-indicator">
<i class="fas fa-spinner fa-spin"></i>
</span>
<i class="fas fa-sync-alt mr-1"></i> Refresh
</button>
</div>
</div>
<div class="lg:col-span-3 pl-0 lg:pl-4">
<div class="flex justify-between items-center mb-2">
<h4 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
if data.CurrentLogFile != "" {
Log: { data.CurrentLogFile }
} else {
Select a log file
}
</h4>
if data.CurrentLogFile != "" {
<div class="flex space-x-2">
<button
class="btn-secondary btn-sm"
hx-get={ fmt.Sprintf("/admin/logs/download/%s", data.CurrentLogFile) }
>
<i class="fas fa-download mr-1"></i> Download
</button>
</div>
}
</div>
@AdminLogContent(data)
</div>
</div>
</div>
}
// AdminLogContent template for log view
templ AdminLogContent(data AdminToolsData) {
<div id="log-content" class="relative">
<div id="log-loading" class="htmx-indicator absolute inset-0 bg-white/75 dark:bg-secondary-800/75 flex items-center justify-center">
<i class="fas fa-spinner fa-spin text-primary-600 text-2xl"></i>
</div>
if data.CurrentLogFile == "" {
<div class="border border-secondary-200 dark:border-secondary-700 rounded p-4 text-secondary-600 dark:text-secondary-400 bg-secondary-50 dark:bg-secondary-900/30 text-center h-96 flex items-center justify-center">
<div>
<i class="fas fa-file-alt text-4xl mb-2"></i>
<p>Select a log file to view its contents</p>
</div>
</div>
} else {
<!-- Fixed height log content container with guaranteed scrollbars -->
<div class="log-content-container" style="height: 400px; border: 1px solid #ccc; border-radius: 0.375rem; position: relative;">
<!-- Standard scrollable div -->
<div id="log-content-text" class="p-4 h-full overflow-y-scroll bg-secondary-50 dark:bg-secondary-900/30 text-secondary-800 dark:text-secondary-200 text-sm font-mono whitespace-pre-wrap" style="scrollbar-width: thin;">
{ data.LogContent }
</div>
<!-- Custom scrollbar -->
<div class="custom-scrollbar dark:bg-white dark:bg-opacity-10" style="position: absolute; right: 0; top: 0; width: 12px; height: 100%; background-color: rgba(0,0,0,0.05); border-radius: 0 0.375rem 0.375rem 0;">
<div class="scrollbar-thumb dark:bg-opacity-30 dark:bg-white" style="position: absolute; right: 0; width: 12px; background-color: rgba(0,0,0,0.3); border-radius: 6px; cursor: pointer; min-height: 40px;"></div>
</div>
</div>
<script>
// Custom scrollbar implementation
(function() {
const content = document.getElementById('log-content-text');
const scrollThumb = document.querySelector('.scrollbar-thumb');
// Initial position
updateScrollThumb();
// Update scrollbar position when content is scrolled
content.addEventListener('scroll', updateScrollThumb);
function updateScrollThumb() {
const scrollPercentage = content.scrollTop / (content.scrollHeight - content.clientHeight);
const thumbHeight = Math.max(40, (content.clientHeight / content.scrollHeight) * content.clientHeight);
const thumbTop = scrollPercentage * (content.clientHeight - thumbHeight);
scrollThumb.style.height = thumbHeight + 'px';
scrollThumb.style.top = thumbTop + 'px';
}
// Dragging the scrollbar
let isDragging = false;
let startY, startTop;
scrollThumb.addEventListener('mousedown', function(e) {
isDragging = true;
startY = e.clientY;
startTop = parseInt(scrollThumb.style.top) || 0;
document.body.style.userSelect = 'none'; // Prevent text selection during drag
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
const deltaY = e.clientY - startY;
const newTop = Math.max(0, Math.min(content.clientHeight - scrollThumb.offsetHeight, startTop + deltaY));
scrollThumb.style.top = newTop + 'px';
// Update scroll position
const scrollPercentage = newTop / (content.clientHeight - scrollThumb.offsetHeight);
content.scrollTop = scrollPercentage * (content.scrollHeight - content.clientHeight);
});
document.addEventListener('mouseup', function() {
isDragging = false;
document.body.style.userSelect = '';
});
// Auto-scroll to bottom
content.scrollTop = content.scrollHeight;
})();
</script>
}
</div>
}
+2 -2
View File
@@ -18,8 +18,8 @@ services:
- TZ=UTC
- DATA_DIR=/app/data
- BACKUP_DIR=/app/backups
- GOMFT_LOGS_DIR=/app/data/logs
# - GOMFT_LOG_LEVEL=info
- LOGS_DIR=/app/data/logs
# - LOG_LEVEL=info
networks:
- gomft-network
+28 -37
View File
@@ -6,46 +6,46 @@ import (
"regexp"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// PasswordPolicy defines the requirements for password strength and management
type PasswordPolicy struct {
MinLength int // Minimum password length
RequireUppercase bool // Require at least one uppercase letter
RequireLowercase bool // Require at least one lowercase letter
RequireNumbers bool // Require at least one number
RequireSpecial bool // Require at least one special character
ExpirationDays int // Number of days until password expires (0 = never)
HistoryCount int // Number of previous passwords to remember (0 = disabled)
DisallowCommon bool // Disallow common passwords
MaxLoginAttempts int // Maximum failed login attempts before lockout
LockoutDuration time.Duration // Duration of account lockout after max failed attempts
MinLength int // Minimum password length
RequireUppercase bool // Require at least one uppercase letter
RequireLowercase bool // Require at least one lowercase letter
RequireNumbers bool // Require at least one number
RequireSpecial bool // Require at least one special character
ExpirationDays int // Number of days until password expires (0 = never)
HistoryCount int // Number of previous passwords to remember (0 = disabled)
DisallowCommon bool // Disallow common passwords
MaxLoginAttempts int // Maximum failed login attempts before lockout
LockoutDuration time.Duration // Duration of account lockout after max failed attempts
}
// PasswordHistory represents a historical password entry
type PasswordHistory struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"not null"`
PasswordHash string `gorm:"not null"`
ID uint `gorm:"primarykey"`
UserID uint `gorm:"not null"`
PasswordHash string `gorm:"not null"`
CreatedAt time.Time
}
// DefaultPasswordPolicy returns the default password policy
func DefaultPasswordPolicy() PasswordPolicy {
return PasswordPolicy{
MinLength: 8,
RequireUppercase: true,
RequireLowercase: true,
RequireNumbers: true,
RequireSpecial: true,
ExpirationDays: 90,
HistoryCount: 5,
DisallowCommon: true,
MaxLoginAttempts: 5,
LockoutDuration: 15 * time.Minute,
MinLength: 8,
RequireUppercase: true,
RequireLowercase: true,
RequireNumbers: true,
RequireSpecial: true,
ExpirationDays: 90,
HistoryCount: 5,
DisallowCommon: true,
MaxLoginAttempts: 5,
LockoutDuration: 15 * time.Minute,
}
}
@@ -127,7 +127,7 @@ func IsPasswordExpired(lastPasswordChange time.Time, policy PasswordPolicy) bool
if policy.ExpirationDays <= 0 {
return false
}
expirationTime := lastPasswordChange.Add(time.Duration(policy.ExpirationDays) * 24 * time.Hour)
return time.Now().After(expirationTime)
}
@@ -143,7 +143,7 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
UserID: userID,
PasswordHash: hashedPassword,
}
if err := db.Create(&passwordHistory).Error; err != nil {
return err
}
@@ -151,13 +151,13 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
// Trim history if needed
var count int64
db.Model(&PasswordHistory{}).Where("user_id = ?", userID).Count(&count)
if count > int64(policy.HistoryCount) {
var oldestHistories []PasswordHistory
if err := db.Where("user_id = ?", userID).Order("created_at asc").Limit(int(count) - policy.HistoryCount).Find(&oldestHistories).Error; err != nil {
return err
}
for _, history := range oldestHistories {
if err := db.Delete(&history).Error; err != nil {
return err
@@ -168,15 +168,6 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
return nil
}
// HashPassword hashes a password using bcrypt
func HashPassword(password string) (string, error) {
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedBytes), nil
}
// ComparePasswords compares a hashed password with a plain text password
func ComparePasswords(hashedPassword, plainPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
-32
View File
@@ -333,16 +333,6 @@ func (db *DB) CreateFileMetadata(metadata *FileMetadata) error {
return db.Create(metadata).Error
}
// GetFileMetadata retrieves file metadata by ID
func (db *DB) GetFileMetadata(id uint) (*FileMetadata, error) {
var metadata FileMetadata
err := db.First(&metadata, id).Error
if err != nil {
return nil, err
}
return &metadata, nil
}
// GetFileMetadataByJobAndName retrieves file metadata by job ID and filename
func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) {
var metadata FileMetadata
@@ -363,18 +353,6 @@ func (db *DB) GetFileMetadataByHash(fileHash string) (*FileMetadata, error) {
return &metadata, nil
}
// UpdateFileMetadata updates an existing file metadata record
func (db *DB) UpdateFileMetadata(metadata *FileMetadata) error {
return db.Save(metadata).Error
}
// GetFileMetadataForJob retrieves all file metadata for a job
func (db *DB) GetFileMetadataForJob(jobID uint) ([]FileMetadata, error) {
var metadata []FileMetadata
err := db.Where("job_id = ?", jobID).Find(&metadata).Error
return metadata, err
}
// DeleteFileMetadata deletes file metadata by ID
func (db *DB) DeleteFileMetadata(id uint) error {
return db.Delete(&FileMetadata{}, id).Error
@@ -392,16 +370,6 @@ func (db *DB) GetConfigRclonePath(config *TransferConfig) string {
return filepath.Join(dataDir, "configs", fmt.Sprintf("config_%d.conf", config.ID))
}
// GetSkipProcessedFilesValue gets the current value of SkipProcessedFiles for a config
func (db *DB) GetSkipProcessedFilesValue(configID uint) (bool, error) {
var value bool
err := db.Model(&TransferConfig{}).
Where("id = ?", configID).
Select("skip_processed_files").
Scan(&value).Error
return value, err
}
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
configPath := db.GetConfigRclonePath(config)
+7 -121
View File
@@ -1,8 +1,6 @@
package scheduler
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -102,7 +100,7 @@ func NewLogger() *Logger {
// Ensure logs directory exists
logsDir := filepath.Join(dataDir, "logs")
if envLogsDir := os.Getenv("GOMFT_LOGS_DIR"); envLogsDir != "" {
if envLogsDir := os.Getenv("LOGS_DIR"); envLogsDir != "" {
logsDir = envLogsDir
}
@@ -112,34 +110,34 @@ func NewLogger() *Logger {
// Get log rotation settings from environment or use defaults
maxSize := 10 // Default: 10MB
if envSize := os.Getenv("GOMFT_LOG_MAX_SIZE"); envSize != "" {
if envSize := os.Getenv("LOG_MAX_SIZE"); envSize != "" {
if size, err := strconv.Atoi(envSize); err == nil && size > 0 {
maxSize = size
}
}
maxBackups := 5 // Default: keep 5 backups
if envBackups := os.Getenv("GOMFT_LOG_MAX_BACKUPS"); envBackups != "" {
if envBackups := os.Getenv("LOG_MAX_BACKUPS"); envBackups != "" {
if backups, err := strconv.Atoi(envBackups); err == nil && backups >= 0 {
maxBackups = backups
}
}
maxAge := 30 // Default: 30 days
if envAge := os.Getenv("GOMFT_LOG_MAX_AGE"); envAge != "" {
if envAge := os.Getenv("LOG_MAX_AGE"); envAge != "" {
if age, err := strconv.Atoi(envAge); err == nil && age >= 0 {
maxAge = age
}
}
compress := true // Default: compress logs
if envCompress := os.Getenv("GOMFT_LOG_COMPRESS"); envCompress == "false" {
if envCompress := os.Getenv("LOG_COMPRESS"); envCompress == "false" {
compress = false
}
// Get log level from environment or use default
logLevel := LogLevelInfo // Default to info level
if envLogLevel := os.Getenv("GOMFT_LOG_LEVEL"); envLogLevel != "" {
if envLogLevel := os.Getenv("LOG_LEVEL"); envLogLevel != "" {
logLevel = ParseLogLevel(envLogLevel)
}
@@ -201,7 +199,7 @@ func New(database *db.DB) *Scheduler {
logger := NewLogger()
logger.Info.Println("Initializing scheduler")
c := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))
c := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
c.Start()
s := &Scheduler{
@@ -944,39 +942,6 @@ func (s *Scheduler) RunJobNow(jobID uint) error {
return nil
}
// calculateFileHash computes an MD5 hash for the given file path
func calculateFileHash(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("error opening file: %v", err)
}
defer file.Close()
hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return "", fmt.Errorf("error calculating hash: %v", err)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// getFileInfo retrieves file stats like size, creation time, and modification time
func getFileInfo(filePath string) (int64, time.Time, time.Time, error) {
info, err := os.Stat(filePath)
if err != nil {
return 0, time.Time{}, time.Time{}, fmt.Errorf("error getting file info: %v", err)
}
size := info.Size()
modTime := info.ModTime()
// Get creation time (this is platform-specific)
// For simplicity, we'll use modification time as a fallback
createTime := modTime
return size, createTime, modTime, nil
}
// hasFileBeenProcessed checks if a file with the same hash has been processed before
func (s *Scheduler) hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error) {
if fileHash == "" {
@@ -1002,82 +967,3 @@ func (s *Scheduler) checkFileProcessingHistory(jobID uint, fileName string) (*db
return nil, fmt.Errorf("no history found for file %s in job %d", fileName, jobID)
}
// getRemoteFileInfo gets metadata for a remote file using rclone lsjson
func (s *Scheduler) getRemoteFileInfo(config *db.TransferConfig, file string) (int64, time.Time, time.Time, string, error) {
// Get rclone config path
configPath := s.db.GetConfigRclonePath(config)
// Construct the appropriate source path
var sourcePath string
if config.SourceType == "s3" || config.SourceType == "minio" || config.SourceType == "b2" {
sourcePath = fmt.Sprintf("source_%d:%s", config.ID, config.SourceBucket)
if config.SourcePath != "" && config.SourcePath != "/" {
sourcePath = fmt.Sprintf("source_%d:%s/%s", config.ID, config.SourceBucket, config.SourcePath)
}
} else {
sourcePath = fmt.Sprintf("source_%d:%s", config.ID, config.SourcePath)
}
// Use rclone lsjson to get file details
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
// Construct the full path to the file
fullPath := fmt.Sprintf("%s/%s", sourcePath, file)
// Run rclone lsjson command
args := []string{
"--config", configPath,
"lsjson",
"--hash",
fullPath,
}
cmd := exec.Command(rclonePath, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return 0, time.Time{}, time.Time{}, "", fmt.Errorf("error getting remote file info: %v", err)
}
// Parse the JSON output
var files []map[string]interface{}
if err := json.Unmarshal(output, &files); err != nil {
return 0, time.Time{}, time.Time{}, "", fmt.Errorf("error parsing lsjson output: %v", err)
}
if len(files) == 0 {
return 0, time.Time{}, time.Time{}, "", fmt.Errorf("file not found: %s", file)
}
fileInfo := files[0]
// Extract file size
var fileSize int64
if size, ok := fileInfo["Size"].(float64); ok {
fileSize = int64(size)
}
// Extract modification time
modTime := time.Now()
if modTimeStr, ok := fileInfo["ModTime"].(string); ok {
if parsedTime, err := time.Parse(time.RFC3339, modTimeStr); err == nil {
modTime = parsedTime
}
}
// Create time is usually not available for remote files, so we'll use modTime
createTime := modTime
// Calculate hash if available
var md5Hash string
if hashes, ok := fileInfo["Hashes"].(map[string]interface{}); ok {
if md5, ok := hashes["md5"].(string); ok {
md5Hash = md5
}
}
return fileSize, createTime, modTime, md5Hash, nil
}
-13
View File
@@ -1,13 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
// HandleBackupDB handles the POST /admin/backup route
func (h *Handlers) HandleBackupDB(c *gin.Context) {
// TODO: Implement database backup
c.JSON(http.StatusOK, gin.H{"message": "Database backup initiated"})
}
+180 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
@@ -23,6 +24,7 @@ func (h *Handlers) HandleAdminTools(c *gin.Context) {
SystemUptime: h.getSystemUptime(),
DatabasePath: h.DBPath,
BackupPath: h.BackupDir,
LogFiles: h.getLogFiles(),
}
// Get database size
@@ -295,19 +297,30 @@ func (h *Handlers) HandleRestoreDatabaseByFilename(c *gin.Context) {
func (h *Handlers) HandleRefreshBackups(c *gin.Context) {
// Get list of backup files
backupFiles := h.getBackupFiles()
// Create data structure for the template
data := components.AdminToolsData{
BackupFiles: backupFiles,
}
// Get last backup time and backup count
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
// Render just the BackupsList component
components.BackupsList(data).Render(c, c.Writer)
}
// HandleRefreshLogs refreshes the log files list
func (h *Handlers) HandleRefreshLogs(c *gin.Context) {
// Get system statistics
data := components.AdminToolsData{
LogFiles: h.getLogFiles(),
}
// Render only the log viewer component
components.AdminLogViewer(data).Render(c, c.Writer)
}
// Helper functions
// getSystemUptime returns the system uptime as a formatted string
@@ -578,3 +591,167 @@ func (h *Handlers) HandleDownloadBackup(c *gin.Context) {
// Serve the file
c.File(filePath)
}
// formatSize converts bytes to human-readable sizes
func formatSize(bytes float64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
TB = GB * 1024
)
switch {
case bytes >= TB:
return fmt.Sprintf("%.2f TB", bytes/TB)
case bytes >= GB:
return fmt.Sprintf("%.2f GB", bytes/GB)
case bytes >= MB:
return fmt.Sprintf("%.2f MB", bytes/MB)
case bytes >= KB:
return fmt.Sprintf("%.2f KB", bytes/KB)
default:
return fmt.Sprintf("%.0f B", bytes)
}
}
// Helper function to get log files
func (h *Handlers) getLogFiles() []components.LogFile {
// Determine logs directory
logsDir := os.Getenv("LOGS_DIR")
if logsDir == "" {
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
logsDir = filepath.Join(dataDir, "logs")
}
// Try to read directory
files, err := ioutil.ReadDir(logsDir)
if err != nil {
return []components.LogFile{}
}
// Process files
var logFiles []components.LogFile
for _, file := range files {
if file.IsDir() {
continue
}
// Only include .log files
if !strings.HasSuffix(strings.ToLower(file.Name()), ".log") {
continue
}
size := formatSize(float64(file.Size()))
logFiles = append(logFiles, components.LogFile{
Name: file.Name(),
Size: size,
ModTime: file.ModTime(),
Path: filepath.Join(logsDir, file.Name()),
})
}
// Sort by modification time (newest first)
sort.Slice(logFiles, func(i, j int) bool {
return logFiles[i].ModTime.After(logFiles[j].ModTime)
})
return logFiles
}
// HandleViewLog displays the contents of a log file
func (h *Handlers) HandleViewLog(c *gin.Context) {
fileName := c.Param("fileName")
if fileName == "" {
c.String(http.StatusBadRequest, "No file name provided")
return
}
// Sanitize the filename to prevent directory traversal
fileName = filepath.Base(fileName)
// Determine logs directory
logsDir := os.Getenv("LOGS_DIR")
if logsDir == "" {
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
logsDir = filepath.Join(dataDir, "logs")
}
filePath := filepath.Join(logsDir, fileName)
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.String(http.StatusNotFound, "Log file not found")
return
}
// Read file contents
content, err := ioutil.ReadFile(filePath)
if err != nil {
c.String(http.StatusInternalServerError, "Error reading log file: "+err.Error())
return
}
// Ensure content is large enough to trigger scrollbar (add padding)
logContent := string(content)
// Add padding at the end to ensure scrollbar is visible even for small logs
if len(logContent) < 2000 {
paddingNeeded := 100 - strings.Count(logContent, "\n")
if paddingNeeded > 0 {
for i := 0; i < paddingNeeded; i++ {
logContent += "\n "
}
}
}
data := components.AdminToolsData{
CurrentLogFile: fileName,
LogContent: logContent,
}
// Render the template using the templ package
components.AdminLogContent(data).Render(c, c.Writer)
}
// HandleDownloadLog allows downloading a log file
func (h *Handlers) HandleDownloadLog(c *gin.Context) {
fileName := c.Param("fileName")
if fileName == "" {
c.String(http.StatusBadRequest, "No file name provided")
return
}
// Sanitize the filename to prevent directory traversal
fileName = filepath.Base(fileName)
// Determine logs directory
logsDir := os.Getenv("LOGS_DIR")
if logsDir == "" {
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
logsDir = filepath.Join(dataDir, "logs")
}
filePath := filepath.Join(logsDir, fileName)
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.String(http.StatusNotFound, "Log file not found")
return
}
// Set headers for file download
c.Header("Content-Description", "File Transfer")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.Header("Content-Type", "text/plain")
c.File(filePath)
}
+6 -39
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/internal/db"
"golang.org/x/crypto/bcrypt"
@@ -55,7 +54,7 @@ func (h *Handlers) HandleAPILogin(c *gin.Context) {
// HandleAPIConfigs handles the GET /api/configs route
func (h *Handlers) HandleAPIConfigs(c *gin.Context) {
userID := c.GetUint("userID")
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
@@ -66,7 +65,7 @@ func (h *Handlers) HandleAPIConfigs(c *gin.Context) {
func (h *Handlers) HandleAPIConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
@@ -109,7 +108,7 @@ func (h *Handlers) HandleAPICreateConfig(c *gin.Context) {
func (h *Handlers) HandleAPIUpdateConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
@@ -150,7 +149,7 @@ func (h *Handlers) HandleAPIUpdateConfig(c *gin.Context) {
func (h *Handlers) HandleAPIDeleteConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
@@ -184,38 +183,6 @@ func (h *Handlers) HandleAPIDeleteConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
}
// HandleAPITestConnection handles the POST /api/configs/test route
func (h *Handlers) HandleAPITestConnection(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
return
}
// TODO: Implement connection testing based on protocol
// This is a placeholder for the actual connection testing logic
success := true
message := "Connection successful"
// Example of how connection testing might work
switch config.SourceType {
case "sftp":
// Test SFTP connection
// success, message = testSFTPConnection(config)
case "ftp":
// Test FTP connection
// success, message = testFTPConnection(config)
default:
success = false
message = "Unsupported source type"
}
c.JSON(http.StatusOK, gin.H{
"success": success,
"message": message,
})
}
// HandleAPIJobs handles the API jobs request
func (h *Handlers) HandleAPIJobs(c *gin.Context) {
// Implementation will be moved from the old handlers.go
@@ -250,7 +217,7 @@ func (h *Handlers) HandleAPIDeleteJob(c *gin.Context) {
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
@@ -284,7 +251,7 @@ func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Job started successfully",
"jobId": job.ID,
+4 -45
View File
@@ -13,7 +13,7 @@ import (
// HandleConfigs handles the GET /configs route
func (h *Handlers) HandleConfigs(c *gin.Context) {
userID := c.GetUint("userID")
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
@@ -36,7 +36,7 @@ func (h *Handlers) HandleNewConfig(c *gin.Context) {
func (h *Handlers) HandleEditConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.Redirect(http.StatusFound, "/configs")
@@ -93,7 +93,7 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
log.Printf("Error finding config: %v", err)
@@ -145,7 +145,7 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
@@ -178,44 +178,3 @@ func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
}
// HandleTestConnection handles the POST /configs/test route
func (h *Handlers) HandleTestConnection(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBind(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid form data: %v", err)})
return
}
// TODO: Implement connection testing based on protocol
// This is a placeholder for the actual connection testing logic
success := true
message := "Connection successful"
// Example of how connection testing might work
switch config.SourceType {
case "sftp":
// Test SFTP connection
// success, message = testSFTPConnection(config)
default:
success = false
message = "Unsupported source type"
}
c.JSON(http.StatusOK, gin.H{
"success": success,
"message": message,
})
}
// HandleTestSFTPConnection handles the test SFTP connection request
func (h *Handlers) HandleTestSFTPConnection(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "Test SFTP connection handler stub"})
}
// HandleBrowseDirectory handles the browse directory request
func (h *Handlers) HandleBrowseDirectory(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "Browse directory handler stub"})
}
+125 -57
View File
@@ -1,8 +1,11 @@
package handlers
import (
"fmt"
"math"
"net/http"
"time"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
@@ -11,96 +14,161 @@ import (
// HandleDashboard handles the GET /dashboard route
func (h *Handlers) HandleDashboard(c *gin.Context) {
// Get recent job history
var recentHistory []db.JobHistory
h.DB.Order("start_time DESC").Limit(5).Find(&recentHistory)
// Get job statistics
var totalJobs int64
h.DB.Model(&db.JobHistory{}).Where("job_histories.status = 'running' AND job_histories.end_time IS NULL").Count(&totalJobs)
var completedJobs int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "completed").Count(&completedJobs)
var failedJobs int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
data := components.DashboardData{
RecentJobs: recentHistory,
ActiveTransfers: int(totalJobs),
CompletedToday: int(completedJobs),
FailedTransfers: int(failedJobs),
}
components.Dashboard(components.CreateTemplateContext(c), data).Render(c, c.Writer)
}
// HandleDashboardStats handles the dashboard stats API request
func (h *Handlers) HandleDashboardStats(c *gin.Context) {
// HandleHistory handles the GET /history route
func (h *Handlers) HandleHistory(c *gin.Context) {
userID := c.GetUint("userID")
// Get job statistics
var activeJobCount int64
var completedJobCount int64
var failedJobCount int64
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "running").Count(&activeJobCount)
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "completed").Count(&completedJobCount)
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "failed").Count(&failedJobCount)
// Get transfer statistics for the last 7 days
var dailyStats []struct {
Date string `json:"date"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
// Get pagination parameters
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
page = 1
}
for i := 6; i >= 0; i-- {
date := time.Now().AddDate(0, 0, -i)
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local)
endOfDay := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, time.Local)
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
if err != nil {
pageSize = 10
}
// Limit page size options
if pageSize != 10 && pageSize != 25 && pageSize != 50 && pageSize != 100 {
pageSize = 10
}
var completed int64
var failed int64
// Get search term
searchTerm := c.Query("search")
h.DB.Model(&db.Job{}).
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "completed", startOfDay, endOfDay).
Count(&completed)
// Build the query
query := h.DB.Model(&db.JobHistory{}).
Joins("JOIN jobs ON jobs.id = job_histories.job_id").
Joins("JOIN transfer_configs ON transfer_configs.id = jobs.config_id").
Where("jobs.created_by = ?", userID)
h.DB.Model(&db.Job{}).
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "failed", startOfDay, endOfDay).
Count(&failed)
// Apply search if provided
if searchTerm != "" {
query = query.Where("transfer_configs.name LIKE ? OR job_histories.status LIKE ?",
"%"+searchTerm+"%", "%"+searchTerm+"%")
}
dailyStats = append(dailyStats, struct {
Date string `json:"date"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
}{
Date: startOfDay.Format("2006-01-02"),
Completed: completed,
Failed: failed,
})
// Count total matching records for pagination
var total int64
query.Count(&total)
// Calculate total pages
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
if totalPages == 0 {
totalPages = 1
}
// Ensure page is within bounds
if page > totalPages {
page = totalPages
}
// Get paginated results
var history []db.JobHistory
offset := (page - 1) * pageSize
query.Offset(offset).
Limit(pageSize).
Preload("Job.Config").
Order("start_time desc").
Find(&history)
// If we got no results and we're not on page 1, redirect to page 1
// Only do this for non-HTMX requests to avoid navigation issues
isHtmxRequest := c.GetHeader("HX-Request") == "true"
if len(history) == 0 && page > 1 && total > 0 && !isHtmxRequest {
redirectURL := fmt.Sprintf("/history?page=1&pageSize=%d", pageSize)
if searchTerm != "" {
redirectURL += fmt.Sprintf("&search=%s", url.QueryEscape(searchTerm))
}
c.Redirect(http.StatusFound, redirectURL)
return
}
data := components.HistoryData{
History: history,
CurrentPage: page,
TotalPages: totalPages,
SearchTerm: searchTerm,
PageSize: pageSize,
Total: int(total),
}
// If this is an HTMX request, only render the history content component
if isHtmxRequest {
components.HistoryContent(c, data).Render(c, c.Writer)
} else {
components.History(c, data).Render(c, c.Writer)
}
}
// HandleDashboardData handles the GET /dashboard/data route
func (h *Handlers) HandleDashboardData(c *gin.Context) {
// Get recent job runs
var recentRuns []db.JobHistory
if err := h.DB.Preload("Job").Order("start_time desc").Limit(5).Find(&recentRuns).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve recent runs"})
return
}
c.JSON(http.StatusOK, gin.H{
"activeJobs": activeJobCount,
"completedJobs": completedJobCount,
"failedJobs": failedJobCount,
"dailyStats": dailyStats,
"uptime": time.Since(h.StartTime).String(),
"uptimeSeconds": int64(time.Since(h.StartTime).Seconds()),
"recent_runs": recentRuns,
})
}
// HandleRecentJobs handles the recent jobs API request
func (h *Handlers) HandleRecentJobs(c *gin.Context) {
userID := c.GetUint("userID")
var recentJobs []db.Job
h.DB.Where("created_by = ?", userID).Order("created_at DESC").Limit(5).Find(&recentJobs)
// HandleDashboardJobsData handles the GET /dashboard/jobs route
func (h *Handlers) HandleDashboardJobsData(c *gin.Context) {
// Get active jobs
var activeJobs []db.Job
if err := h.DB.Where("enabled = ?", true).Find(&activeJobs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve active jobs"})
return
}
c.JSON(http.StatusOK, gin.H{
"recentJobs": recentJobs,
"active_jobs": activeJobs,
})
}
// HandleDashboardHistoryData handles the GET /dashboard/history route
func (h *Handlers) HandleDashboardHistoryData(c *gin.Context) {
// Get job history stats
var successCount int64
var failureCount int64
var pendingCount int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "success").Count(&successCount)
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failure").Count(&failureCount)
h.DB.Model(&db.JobHistory{}).Where("status = ?", "pending").Count(&pendingCount)
c.JSON(http.StatusOK, gin.H{
"success_count": successCount,
"failure_count": failureCount,
"pending_count": pendingCount,
})
}
+6 -28
View File
@@ -1,7 +1,6 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
@@ -24,55 +23,34 @@ func (h *Handlers) HandleProfile(c *gin.Context) {
func (h *Handlers) HandleUpdateTheme(c *gin.Context) {
userID := c.GetUint("userID")
theme := c.PostForm("theme")
// Validate theme value
validThemes := map[string]bool{
"light": true,
"dark": true,
"system": true,
}
if !validThemes[theme] {
c.Status(http.StatusBadRequest)
return
}
// Update user theme preference
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.Status(http.StatusInternalServerError)
return
}
user.Theme = theme
if err := h.DB.Save(&user).Error; err != nil {
c.Status(http.StatusInternalServerError)
return
}
// Set theme cookie for client-side theme switching
c.SetCookie("theme", theme, 60*60*24*365, "/", "", false, false)
c.Status(http.StatusOK)
}
// HandleUpdateProfile handles the POST /profile/update route
func (h *Handlers) HandleUpdateProfile(c *gin.Context) {
userID := c.GetUint("userID")
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.String(http.StatusNotFound, "User not found")
return
}
// Update user fields
user.Email = c.PostForm("email")
if err := h.DB.Save(&user).Error; err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update profile: %v", err))
return
}
c.Redirect(http.StatusFound, "/profile")
}
+7 -148
View File
@@ -1,151 +1,9 @@
package handlers
import (
"fmt"
"math"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleHistory handles the GET /history route
func (h *Handlers) HandleHistory(c *gin.Context) {
userID := c.GetUint("userID")
// Get pagination parameters
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
if err != nil {
pageSize = 10
}
// Limit page size options
if pageSize != 10 && pageSize != 25 && pageSize != 50 && pageSize != 100 {
pageSize = 10
}
// Get search term
searchTerm := c.Query("search")
// Build the query
query := h.DB.Model(&db.JobHistory{}).
Joins("JOIN jobs ON jobs.id = job_histories.job_id").
Joins("JOIN transfer_configs ON transfer_configs.id = jobs.config_id").
Where("jobs.created_by = ?", userID)
// Apply search if provided
if searchTerm != "" {
query = query.Where("transfer_configs.name LIKE ? OR job_histories.status LIKE ?",
"%"+searchTerm+"%", "%"+searchTerm+"%")
}
// Count total matching records for pagination
var total int64
query.Count(&total)
// Calculate total pages
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
if totalPages == 0 {
totalPages = 1
}
// Ensure page is within bounds
if page > totalPages {
page = totalPages
}
// Get paginated results
var history []db.JobHistory
offset := (page - 1) * pageSize
query.Offset(offset).
Limit(pageSize).
Preload("Job.Config").
Order("start_time desc").
Find(&history)
// If we got no results and we're not on page 1, redirect to page 1
// Only do this for non-HTMX requests to avoid navigation issues
isHtmxRequest := c.GetHeader("HX-Request") == "true"
if len(history) == 0 && page > 1 && total > 0 && !isHtmxRequest {
redirectURL := fmt.Sprintf("/history?page=1&pageSize=%d", pageSize)
if searchTerm != "" {
redirectURL += fmt.Sprintf("&search=%s", url.QueryEscape(searchTerm))
}
c.Redirect(http.StatusFound, redirectURL)
return
}
data := components.HistoryData{
History: history,
CurrentPage: page,
TotalPages: totalPages,
SearchTerm: searchTerm,
PageSize: pageSize,
Total: int(total),
}
// If this is an HTMX request, only render the history content component
if isHtmxRequest {
components.HistoryContent(c, data).Render(c, c.Writer)
} else {
components.History(c, data).Render(c, c.Writer)
}
}
// HandleDashboardData handles the GET /dashboard/data route
func (h *Handlers) HandleDashboardData(c *gin.Context) {
// Get recent job runs
var recentRuns []db.JobHistory
if err := h.DB.Preload("Job").Order("start_time desc").Limit(5).Find(&recentRuns).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve recent runs"})
return
}
c.JSON(http.StatusOK, gin.H{
"recent_runs": recentRuns,
})
}
// HandleDashboardJobsData handles the GET /dashboard/jobs route
func (h *Handlers) HandleDashboardJobsData(c *gin.Context) {
// Get active jobs
var activeJobs []db.Job
if err := h.DB.Where("enabled = ?", true).Find(&activeJobs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve active jobs"})
return
}
c.JSON(http.StatusOK, gin.H{
"active_jobs": activeJobs,
})
}
// HandleDashboardHistoryData handles the GET /dashboard/history route
func (h *Handlers) HandleDashboardHistoryData(c *gin.Context) {
// Get job history stats
var successCount int64
var failureCount int64
var pendingCount int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "success").Count(&successCount)
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failure").Count(&failureCount)
h.DB.Model(&db.JobHistory{}).Where("status = ?", "pending").Count(&pendingCount)
c.JSON(http.StatusOK, gin.H{
"success_count": successCount,
"failure_count": failureCount,
"pending_count": pendingCount,
})
}
// RegisterRoutes registers all the routes for the web interface
func (h *Handlers) RegisterRoutes(router *gin.Engine) {
// Public routes
@@ -171,14 +29,14 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.GET("/configs/:id", h.HandleEditConfig)
authorized.POST("/configs", h.HandleCreateConfig)
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
authorized.POST("/configs/:id", h.HandleUpdateConfig) // Add POST route for form submission
authorized.POST("/configs/:id", h.HandleUpdateConfig)
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
authorized.GET("/jobs", h.HandleJobs)
authorized.GET("/jobs/new", h.HandleNewJob)
authorized.GET("/jobs/:id", h.HandleEditJob)
authorized.POST("/jobs", h.HandleCreateJob)
authorized.PUT("/jobs/:id", h.HandleUpdateJob)
authorized.POST("/jobs/:id", h.HandleUpdateJob) // Add POST route for form submission
authorized.POST("/jobs/:id", h.HandleUpdateJob)
authorized.DELETE("/jobs/:id", h.HandleDeleteJob)
authorized.POST("/jobs/:id/run", h.HandleRunJob)
authorized.GET("/history", h.HandleHistory)
@@ -196,10 +54,6 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.GET("/dashboard/jobs", h.HandleDashboardJobsData)
authorized.GET("/dashboard/history", h.HandleDashboardHistoryData)
// Test connection routes
authorized.POST("/test-connection", h.HandleTestConnection)
authorized.POST("/test-sftp-connection", h.HandleTestSFTPConnection)
authorized.POST("/browse-directory", h.HandleBrowseDirectory)
}
// Admin-only routes
@@ -225,6 +79,11 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
admin.GET("/refresh-backups", h.HandleRefreshBackups)
// Log viewer routes
admin.GET("/logs/refresh", h.HandleRefreshLogs)
admin.GET("/logs/view/:fileName", h.HandleViewLog)
admin.GET("/logs/download/:fileName", h.HandleDownloadLog)
}
// API routes
+19 -178
View File
@@ -1,8 +1,6 @@
package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"time"
@@ -40,34 +38,34 @@ func (h *Handlers) HandleCreateUser(c *gin.Context) {
email := c.PostForm("email")
password := c.PostForm("password")
isAdmin := c.PostForm("is_admin") == "on"
// Check if email already exists
var existingUser db.User
if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
c.String(http.StatusBadRequest, "Email already exists")
return
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
// Create the user
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: isAdmin,
IsAdmin: isAdmin,
LastPasswordChange: time.Now(),
}
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
return
}
c.Redirect(http.StatusSeeOther, "/admin/users")
}
@@ -78,20 +76,20 @@ func (h *Handlers) HandleDeleteUser(c *gin.Context) {
c.String(http.StatusBadRequest, "Invalid user ID")
return
}
// Don't allow deleting the current user
currentUserID := c.GetUint("userID")
if uint(userID) == currentUserID {
c.String(http.StatusBadRequest, "Cannot delete your own account")
return
}
// Delete the user
if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to delete user")
return
}
c.Redirect(http.StatusSeeOther, "/admin/users")
}
@@ -100,13 +98,13 @@ func (h *Handlers) HandleRegisterPage(c *gin.Context) {
// Check if any users exist
var count int64
h.DB.Model(&db.User{}).Count(&count)
// If users exist, don't allow registration
if count > 0 {
c.Redirect(http.StatusSeeOther, "/")
return
}
components.Register(c.Request.Context(), "").Render(c, c.Writer)
}
@@ -115,23 +113,23 @@ func (h *Handlers) HandleRegister(c *gin.Context) {
// Check if any users exist
var count int64
h.DB.Model(&db.User{}).Count(&count)
// If users exist, don't allow registration
if count > 0 {
c.Redirect(http.StatusSeeOther, "/")
return
}
email := c.PostForm("email")
password := c.PostForm("password")
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
// Create the admin user
user := db.User{
Email: email,
@@ -139,178 +137,21 @@ func (h *Handlers) HandleRegister(c *gin.Context) {
IsAdmin: true,
LastPasswordChange: time.Now(),
}
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
return
}
// Generate JWT
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to generate token")
return
}
// Set cookie
c.SetCookie("jwt", token, 60*60*24, "/", "", false, true)
c.Redirect(http.StatusSeeOther, "/dashboard")
}
// HandleEditUser handles the edit user page request
func (h *Handlers) HandleEditUser(c *gin.Context) {
// Only admin users can access this page
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.Redirect(http.StatusFound, "/dashboard")
return
}
id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
c.Redirect(http.StatusFound, "/users")
return
}
data := components.UserFormData{
IsNew: false,
ErrorMessage: "",
}
components.UserForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleUpdateUser handles the update user form submission
func (h *Handlers) HandleUpdateUser(c *gin.Context) {
// Only admin users can update users
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "Only administrators can update users")
return
}
id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
log.Printf("Error finding user: %v", err)
c.String(http.StatusNotFound, "User not found")
return
}
// Get the old user values for comparison
oldUser := user
// Bind form data to user
if err := c.ShouldBind(&user); err != nil {
log.Printf("Error binding user form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
return
}
// Check if email already exists for a different user
var existingUser db.User
if user.Email != oldUser.Email {
if err := h.DB.Where("email = ? AND id != ?", user.Email, user.ID).First(&existingUser).Error; err == nil {
c.String(http.StatusBadRequest, "Email already in use")
return
}
}
// Get password from form
password := c.PostForm("password")
// Only update password if provided
if password != "" {
// Validate password complexity
if !h.validatePasswordComplexity(password) {
c.String(http.StatusBadRequest, "Password does not meet complexity requirements")
return
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
} else {
// Preserve the old password if not updating
user.PasswordHash = oldUser.PasswordHash
user.LastPasswordChange = oldUser.LastPasswordChange
}
// Preserve fields that shouldn't be updated
user.CreatedAt = oldUser.CreatedAt
user.FailedLoginAttempts = oldUser.FailedLoginAttempts
user.AccountLocked = oldUser.AccountLocked
user.LockoutUntil = oldUser.LockoutUntil
// Update admin status
user.IsAdmin = c.PostForm("is_admin") == "on"
if err := h.DB.Save(&user).Error; err != nil {
log.Printf("Error updating user: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update user: %v", err))
return
}
c.Redirect(http.StatusFound, "/users")
}
// HandleUnlockUser handles the unlock user request
func (h *Handlers) HandleUnlockUser(c *gin.Context) {
// Only admin users can unlock users
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "Only administrators can unlock users"})
return
}
id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Unlock user
user.AccountLocked = false
user.FailedLoginAttempts = 0
user.LockoutUntil = nil
if err := h.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to unlock user: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User unlocked successfully"})
}
// validatePasswordComplexity validates that a password meets complexity requirements
func (h *Handlers) validatePasswordComplexity(password string) bool {
// Password must be at least 8 characters long
if len(password) < 8 {
return false
}
// Check for at least one uppercase letter, one lowercase letter, and one number
hasUpper := false
hasLower := false
hasNumber := false
for _, char := range password {
if char >= 'A' && char <= 'Z' {
hasUpper = true
} else if char >= 'a' && char <= 'z' {
hasLower = true
} else if char >= '0' && char <= '9' {
hasNumber = true
}
}
return hasUpper && hasLower && hasNumber
}
-45
View File
@@ -1,45 +0,0 @@
// AuthMiddleware is a middleware function that checks if the request has a valid JWT token
func (m *Middleware) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get token from cookie
tokenString, err := c.Cookie("jwt_token")
if err != nil {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Parse and validate token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(m.JWTSecret), nil
})
if err != nil || !token.Valid {
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Set user information in context
c.Set("userID", uint(claims["user_id"].(float64)))
c.Set("email", claims["email"].(string))
c.Set("username", claims["username"].(string))
c.Set("isAdmin", claims["is_admin"].(bool))
c.Next()
}
}