Improved logs UI design, added stats

This commit is contained in:
Marc Ole Bulling
2026-01-28 00:28:55 +01:00
parent b1476f0f30
commit c3a6c092fb
15 changed files with 367 additions and 61 deletions

View File

@@ -15,6 +15,7 @@ import (
"github.com/forceu/gokapi/internal/configuration/database/migration"
"github.com/forceu/gokapi/internal/helper/systemd"
"github.com/forceu/gokapi/internal/logging/serverStats"
"github.com/forceu/gokapi/internal/configuration"
"github.com/forceu/gokapi/internal/configuration/cloudconfig"
@@ -69,6 +70,7 @@ func main() {
storage.CleanUp(true)
logging.LogStartup()
showDeprecationWarnings()
serverStats.Init()
go webserver.Start()
c := make(chan os.Signal)
@@ -81,6 +83,7 @@ func main() {
func shutdown() {
fmt.Println("Shutting down...")
webserver.Shutdown()
serverStats.Shutdown()
logging.LogShutdown()
database.Close()
}

View File

@@ -353,3 +353,15 @@ func SaveFileRequest(request models.FileRequest) {
func DeleteFileRequest(request models.FileRequest) {
db.DeleteFileRequest(request)
}
// Statistics
// GetStatTraffic returns the total traffic from statistics
func GetStatTraffic() uint64 {
return db.GetStatTraffic()
}
// SaveStatTraffic stores the total traffic
func SaveStatTraffic(totalTraffic uint64) {
db.SaveStatTraffic(totalTraffic)
}

View File

@@ -108,6 +108,11 @@ type Database interface {
SaveFileRequest(request models.FileRequest)
// DeleteFileRequest deletes a file request with the given ID
DeleteFileRequest(request models.FileRequest)
// GetStatTraffic returns the total traffic from statistics
GetStatTraffic() uint64
// SaveStatTraffic stores the total traffic
SaveStatTraffic(totalTraffic uint64)
}
// GetNew connects to the given database and initialises it

View File

@@ -257,6 +257,16 @@ func (p DatabaseProvider) getKeyInt(id string) (int, bool) {
helper.Check(err2)
return resultInt, true
}
func (p DatabaseProvider) getKeyUInt64(id string) (uint64, bool) {
result, err := p.getKeyRaw(id)
if result == nil {
return 0, false
}
resultInt, err2 := redigo.Uint64(result, err)
helper.Check(err2)
return resultInt, true
}
func (p DatabaseProvider) getKeyBytes(id string) ([]byte, bool) {
result, err := p.getKeyRaw(id)
if result == nil {

View File

@@ -0,0 +1,16 @@
package redis
const (
prefixStatisticsTraffic = "stats:traffic"
)
// GetStatTraffic returns the total traffic from statistics
func (p DatabaseProvider) GetStatTraffic() uint64 {
result, _ := p.getKeyUInt64(prefixStatisticsTraffic)
return result
}
// SaveStatTraffic stores the total traffic
func (p DatabaseProvider) SaveStatTraffic(totalTraffic uint64) {
p.setKey(prefixStatisticsTraffic, totalTraffic)
}

View File

@@ -21,7 +21,7 @@ type DatabaseProvider struct {
}
// DatabaseSchemeVersion contains the version number to be expected from the current database. If lower, an upgrade will be performed
const DatabaseSchemeVersion = 12
const DatabaseSchemeVersion = 13
// New returns an instance
func New(dbConfig models.DbConnection) (DatabaseProvider, error) {
@@ -46,23 +46,23 @@ func (p DatabaseProvider) Upgrade(currentDbVersion int) {
err := p.rawSqlite("ALTER TABLE FileMetaData DROP COLUMN ExpireAtString;")
helper.Check(err)
}
// < v2.2.0
// < v2.2.0-rc1
if currentDbVersion < 12 {
err := p.rawSqlite(`ALTER TABLE FileMetaData ADD COLUMN "UploadRequestId" TEXT NOT NULL DEFAULT '';
ALTER TABLE ApiKeys ADD COLUMN "UploadRequestId" TEXT NOT NULL DEFAULT '';
CREATE TABLE "UploadRequests" (
"id" TEXT NOT NULL UNIQUE,
"name" TEXT NOT NULL,
"userid" INTEGER NOT NULL,
"expiry" INTEGER NOT NULL,
"maxFiles" INTEGER NOT NULL,
"maxSize" INTEGER NOT NULL,
"creation" INTEGER NOT NULL,
"apiKey" TEXT NOT NULL UNIQUE,
"note" TEXT NOT NULL,
PRIMARY KEY("id")
);`)
ALTER TABLE ApiKeys ADD COLUMN "UploadRequestId" TEXT NOT NULL DEFAULT '';
CREATE TABLE "UploadRequests" (
"id" TEXT NOT NULL UNIQUE,
"name" TEXT NOT NULL,
"userid" INTEGER NOT NULL,
"expiry" INTEGER NOT NULL,
"maxFiles" INTEGER NOT NULL,
"maxSize" INTEGER NOT NULL,
"creation" INTEGER NOT NULL,
"apiKey" TEXT NOT NULL UNIQUE,
"note" TEXT NOT NULL,
PRIMARY KEY("id")
);`)
helper.Check(err)
grantUploadPerm := environment.New().PermRequestGrantedByDefault
for _, user := range p.GetAllUsers() {
@@ -77,6 +77,16 @@ func (p DatabaseProvider) Upgrade(currentDbVersion int) {
}
}
}
// < v2.2.0-rc2
if currentDbVersion < 13 {
err := p.rawSqlite(`CREATE TABLE "Statistics" (
"id" INTEGER NOT NULL,
"type" INTEGER NOT NULL UNIQUE,
"value" INTEGER,
PRIMARY KEY("id" AUTOINCREMENT)
);`)
helper.Check(err)
}
}
// GetDbVersion gets the version number of the database
@@ -223,7 +233,13 @@ func (p DatabaseProvider) createNewDatabase() error {
"apiKey" TEXT NOT NULL UNIQUE,
"note" TEXT NOT NULL,
PRIMARY KEY("id")
);`
);
CREATE TABLE "Statistics" (
"id" INTEGER NOT NULL,
"type" INTEGER NOT NULL UNIQUE,
"value" INTEGER,
PRIMARY KEY("id" AUTOINCREMENT)
);`
err := p.rawSqlite(sqlStmt)
if err != nil {
return err

View File

@@ -3,9 +3,10 @@ package sqlite
import (
"database/sql"
"errors"
"time"
"github.com/forceu/gokapi/internal/helper"
"github.com/forceu/gokapi/internal/models"
"time"
)
type schemaSessions struct {

View File

@@ -0,0 +1,31 @@
package sqlite
import (
"database/sql"
"errors"
"github.com/forceu/gokapi/internal/helper"
)
const statIdTraffic = "1"
// GetStatTraffic returns the total traffic from statistics
func (p DatabaseProvider) GetStatTraffic() uint64 {
var result uint64
row := p.sqliteDb.QueryRow("SELECT value FROM Statistics WHERE type = ?", statIdTraffic)
err := row.Scan(&result)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0
}
helper.Check(err)
return 0
}
return result
}
// SaveStatTraffic stores the total traffic
func (p DatabaseProvider) SaveStatTraffic(totalTraffic uint64) {
_, err := p.sqliteDb.Exec("INSERT OR REPLACE INTO Statistics (type, value) VALUES (?, ?)", statIdTraffic, totalTraffic)
helper.Check(err)
}

View File

@@ -0,0 +1,60 @@
package serverStats
import (
"sync"
"time"
"github.com/forceu/gokapi/internal/configuration/database"
"github.com/forceu/gokapi/internal/models"
)
const trafficSaveInterval = 5 * time.Minute
var startTime time.Time
var currentTraffic trafficInfo
type trafficInfo struct {
Total uint64
Mutex sync.RWMutex
LastUpdate time.Time
}
func Init() {
startTime = time.Now()
}
func Shutdown() {
saveTraffic()
}
func saveTraffic() {
currentTraffic.Mutex.RLock()
database.SaveStatTraffic(currentTraffic.Total)
currentTraffic.Mutex.RUnlock()
}
func GetUptime() int64 {
return time.Since(startTime).Milliseconds() / 1000
}
func GetTotalFiles() int {
return len(database.GetAllMetadata())
}
func GetCurrentTraffic() uint64 {
currentTraffic.Mutex.RLock()
defer currentTraffic.Mutex.RUnlock()
return currentTraffic.Total
}
func AddTraffic(file models.File) {
currentTraffic.Mutex.Lock()
currentTraffic.Total = currentTraffic.Total + uint64(file.SizeBytes)
requireSave := time.Since(currentTraffic.LastUpdate) > trafficSaveInterval
currentTraffic.LastUpdate = time.Now()
currentTraffic.Mutex.Unlock()
if requireSave {
saveTraffic()
}
}

View File

@@ -29,6 +29,7 @@ import (
"github.com/forceu/gokapi/internal/encryption"
"github.com/forceu/gokapi/internal/helper"
"github.com/forceu/gokapi/internal/logging"
"github.com/forceu/gokapi/internal/logging/serverStats"
"github.com/forceu/gokapi/internal/models"
"github.com/forceu/gokapi/internal/storage"
"github.com/forceu/gokapi/internal/storage/filerequest"
@@ -739,8 +740,17 @@ type AdminView struct {
MinLengthPassword int
FileRequestMaxFiles int
FileRequestMaxSize int
CpuLoad int
MemoryUsage int
MemoryTotal int
DiskUsage int
TotalFiles int
DataServed int64
Uptime int64
TimeNow int64
CustomContent customStatic
TotalTraffic uint64
CustomContent customStatic
}
// getUserMap needs to return the map with pointers; otherwise template cannot call
@@ -807,6 +817,9 @@ func (u *AdminView) convertGlobalConfig(view int, user models.User) *AdminView {
apiKeyList = sortApiKeys(apiKeyList)
case ViewLogs:
u.Logs, _ = logging.GetAll()
u.TotalFiles = serverStats.GetTotalFiles()
u.Uptime = serverStats.GetUptime()
u.TotalTraffic = serverStats.GetCurrentTraffic()
case ViewUsers:
uploadCounts := storage.GetUploadCounts()
u.Users = make([]userInfo, 0)

View File

@@ -57,6 +57,11 @@ a:hover {
filter: brightness(80%);
}
.text-muted {
color: #adb5bd !important;
}
.dropzone {
background: rgb(47, 52, 58) !important;
color: #ffffff;
@@ -647,3 +652,4 @@ tr.no-bottom-border td {
cursor: not-allowed;
opacity: 1;
}

File diff suppressed because one or more lines are too long

View File

@@ -11,8 +11,13 @@ function filterLogs(tag) {
textarea.scrollTop = textarea.scrollHeight;
}
function deleteLogs(cutoff) {
if (cutoff == "none") {
function deleteLogs() {
const delSelector = document.getElementById("deleteLogsSel");
if (!delSelector) {
return;
}
const cutoff = delSelector.value;
if (cutoff == "none" || cutoff == "") {
return;
}
if (!confirm("Do you want to delete the selected logs?")) {
@@ -36,6 +41,7 @@ function deleteLogs(cutoff) {
case "30":
timestamp = timestamp - 30 * 24 * 60 * 60;
break;
default: return;
}
apiLogsDelete(timestamp)
.then(data => {

File diff suppressed because one or more lines are too long

View File

@@ -1,54 +1,181 @@
{{ define "logs" }}{{ template "header" . }}
<div class="row">
<div class="col">
<div id="container" class="card" style="width: 80%">
<div class="card-body">
<h3 class="card-title">Log File</h3>
<br>
<textarea class="form-control" id="logviewer" rows="20" readonly>
{{ .Logs }}</textarea>
<br>
<div class="d-flex gap-3">
<select id="logFilter" class="form-select" onchange="filterLogs(this.value)">
<option value="all">Show all logs</option>
<option value="warning">Show [warning]</option>
<option value="auth">Show [auth]</option>
<option value="download">Show [download]</option>
<option value="upload">Show [upload]</option>
<option value="edit">Show [edit]</option>
<option value="info">Show [info]</option>
</select>
<style>
.stat-card {
background: #1a1d20;
border: 1px solid #2d3238;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 10px 20px rgba(0,0,0,0.3) !important;
border-color: #0d6efd;
}
.stat-icon {
opacity: 0.6;
font-size: 1.2rem;
}
.progress-stat {
height: 4px;
background-color: #2b3035;
}
.log-dark-input {
background-color: #2b3035 !important;
border-color: #444b52 !important;
color: #e9ecef !important;
}
.log-dark-input:focus {
background-color: #32383e !important;
border-color: #0d6efd !important;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.input-group-text-dark {
background-color: #1a1d20 !important;
border-color: #444b52 !important;
color: #adb5bd !important;
}
#logviewer::-webkit-scrollbar { width: 8px; }
#logviewer::-webkit-scrollbar-track { background: #000; }
#logviewer::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
</style>
<div class="container-fluid py-4" style="max-width: 1200px;">
<div class="row g-3 mb-4">
<div class="col-6 col-lg-2">
<div class="card stat-card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="text-muted small text-uppercase fw-bold mb-1">Uptime</div>
<div class="h5 mb-0 text-light">24d 12h</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card stat-card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="text-muted small text-uppercase fw-bold mb-1">CPU Load</div>
<div class="h5 mb-2 text-light">12%</div>
<div class="progress progress-stat">
<div class="progress-bar bg-info" style="width: 12%"></div>
</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card stat-card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="text-muted small text-uppercase fw-bold mb-1">Memory</div>
<div class="h5 mb-2 text-light text-nowrap">142 / 512<span class="small opacity-50">MB</span></div>
<div class="progress progress-stat">
<div class="progress-bar bg-success" style="width: 27%"></div>
</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card stat-card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="text-muted small text-uppercase fw-bold mb-1">Disk Space</div>
<div class="h5 mb-2 text-light">68%</div>
<div class="progress progress-stat">
<div class="progress-bar bg-warning" style="width: 68%"></div>
</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card stat-card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="text-muted small text-uppercase fw-bold mb-1">Data Served</div>
<div class="h5 mb-0 text-light">1.24 <span class="small opacity-50">TB</span></div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card stat-card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="text-muted small text-uppercase fw-bold mb-1">Active Files</div>
<div class="h5 mb-0 text-light">{{ .TotalFiles }} <span class="small opacity-50">Total</span></div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-lg bg-dark overflow-hidden">
<div class="card-header bg-dark border-bottom border-secondary py-3 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<h6 class="mb-0 fw-bold text-light me-3">System Logs</h6>
</div>
<div class="text-muted small font-monospace">gokapi_v1.x.x</div>
</div>
<div class="card-body p-0 bg-black">
<textarea
class="form-control bg-black text-info font-monospace border-0 p-4"
id="logviewer"
rows="20"
readonly
style="resize: none; font-size: 0.85rem; line-height: 1.5; outline: none; border-radius: 0;"
>{{ .Logs }}</textarea>
</div>
<div class="card-footer bg-dark border-top border-secondary p-3">
<div class="row g-2 align-items-center">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text input-group-text-dark">Filter</span>
<select id="logFilter" class="form-select log-dark-input" onchange="filterLogs(this.value)">
<option value="all">All Events</option>
<option value="warning">Warnings</option>
<option value="auth">Security</option>
<option value="download">Downloads</option>
<option value="upload">Uploads</option>
</select>
</div>
</div>
<div class="col-md-2"></div>
<div class="col-md-5">
<div class="d-flex gap-2 justify-content-md-end">
{{ if or (eq .ActiveUser.UserLevel 0) (eq .ActiveUser.UserLevel 1) }}
<select id="deleteLogs" class="form-select" onchange="deleteLogs(this.value)">
<option value="none">Delete Logs...</option>
<option value="2">Older than 2 days</option>
<option value="7">Older than 7 days</option>
<option value="14">Older than 14 days</option>
<option value="30">Older than 30 days</option>
<option value="all">Delete all logs</option>
</select>
<select id="deleteLogsSel" class="form-select form-select-sm log-dark-input w-auto">
<option value="none">Delete Logs...</option>
<option value="30">Older than 30 days</option>
<option value="14">Older than 14 days</option>
<option value="7">Older than 7 days</option>
<option value="2">Older than 2 days</option>
<option value="all">Delete all logs</option>
</select>
<button class="btn btn-sm btn-outline-light px-3" onclick="deleteLogs()">Execute</button>
{{ else }}
<select id="deleteLogs" disabled class="form-select disabled" onchange="">
<select id="deleteLogsSel" disabled class="form-select form-select-sm log-dark-input w-auto disabled">
<option value="none">Only administrators can delete logs</option>
</select>
{{ end }}
<button class="btn btn-sm btn-outline-light px-3" disabled onclick="">Execute</button>
{{ end }}
</div>
</div>
<br>
</div>
</div>
</div>
</div>
<script src="./js/min/admin.min.{{ template "js_admin_version"}}.js"></script>
<script>
let textarea = document.getElementById('logviewer');
textarea.scrollTop = textarea.scrollHeight;
var logContent = textarea.value;
</script>
<script>
const textarea = document.getElementById('logviewer');
var logContent = textarea.value;
textarea.scrollTop = textarea.scrollHeight;
</script>
{{ template "pagename" "LogOverview"}}
{{ template "customjs" .}}
{{ template "footer" true}}