WIP new WebAdmin: connections page

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-01-20 15:35:05 +01:00
parent 73b2573b14
commit 8648351fc7
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
13 changed files with 413 additions and 347 deletions

14
go.mod
View file

@ -10,16 +10,16 @@ require (
github.com/alexedwards/argon2id v1.0.0
github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964
github.com/aws/aws-sdk-go-v2 v1.24.1
github.com/aws/aws-sdk-go-v2/config v1.26.4
github.com/aws/aws-sdk-go-v2/credentials v1.16.15
github.com/aws/aws-sdk-go-v2/config v1.26.5
github.com/aws/aws-sdk-go-v2/credentials v1.16.16
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.12
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.13
github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.19.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.2
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7
github.com/bmatcuk/doublestar/v4 v4.6.1
github.com/cockroachdb/cockroach-go/v2 v2.3.5
github.com/cockroachdb/cockroach-go/v2 v2.3.6
github.com/coreos/go-oidc/v3 v3.9.0
github.com/drakkan/webdav v0.0.0-20230227175313-32996838bcd8
github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
@ -74,7 +74,7 @@ require (
golang.org/x/sys v0.16.0
golang.org/x/term v0.16.0
golang.org/x/time v0.5.0
google.golang.org/api v0.156.0
google.golang.org/api v0.157.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
@ -94,7 +94,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@ -164,7 +164,7 @@ require (
go.opentelemetry.io/otel/metric v1.22.0 // indirect
go.opentelemetry.io/otel/trace v1.22.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/text v0.14.0 // indirect

28
go.sum
View file

@ -37,14 +37,14 @@ github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=
github.com/aws/aws-sdk-go-v2/config v1.26.4 h1:Juj7LhtxNudNUlfX22K5AnLafO+v4eq9PA3VWSCIQs4=
github.com/aws/aws-sdk-go-v2/config v1.26.4/go.mod h1:tioqQ7wvxMYnTDpoTTLHhV3Zh+z261i/f2oz+ds8eNI=
github.com/aws/aws-sdk-go-v2/credentials v1.16.15 h1:P0/m1LU08MF2kRzx4P//+7lNjiJod1z4xI2WpWhdpTQ=
github.com/aws/aws-sdk-go-v2/credentials v1.16.15/go.mod h1:pgtMCf7Dx4GWw5EpHOTc2Sy17LIP0A0N2C9nQ83pQ/0=
github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw=
github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.12 h1:0FMZy36RSYvcvVzEf1xbNdebLHZewW40QWP+P8jCMVk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.12/go.mod h1:+chyahvarkb3HibkNei9IQEM9P5cWD5w2kgXCa3Hh0I=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.13 h1:8Nt4LBUEKV0FxLBO2BmRzDKax3hp2LRMKySMBwL4vMc=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.13/go.mod h1:t5QEDu/FBJJM4kslbQlTSpYtnhoWDNmHSsgQojIxE0o=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
@ -67,8 +67,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0 h1:PJTdBMsyvra6FtED7JZtDpQrIAflY
github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0/go.mod h1:4qXHrG1Ne3VGIMZPCB8OjH/pLFO94sKABIusjh0KWPU=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.2 h1:A5sGOT/mukuU+4At1vkSIWAN8tPwPCoYZBp7aruR540=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.2/go.mod h1:qutL00aW8GSo2D0I6UEOqMvRS3ZyuBrOC1BLe5D2jPc=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.6 h1:dGrs+Q/WzhsiUKh82SfTVN66QzyulXuMDTV/G8ZxOac=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.6/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
@ -93,8 +93,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+gqO04wryn5h75LSazbRlnya1k=
github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/cockroach-go/v2 v2.3.5 h1:Khtm8K6fTTz/ZCWPzU9Ne3aOW9VyAnj4qIPCJgKtwK0=
github.com/cockroachdb/cockroach-go/v2 v2.3.5/go.mod h1:1wNJ45eSXW9AnOc3skntW9ZUZz6gxrQK3cOj3rK+BC8=
github.com/cockroachdb/cockroach-go/v2 v2.3.6 h1:Wlv9TzkrG9V7i6u8dEtmXPrBzvfFp+CgJNs696rAajM=
github.com/cockroachdb/cockroach-go/v2 v2.3.6/go.mod h1:1wNJ45eSXW9AnOc3skntW9ZUZz6gxrQK3cOj3rK+BC8=
github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
@ -428,8 +428,8 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
gocloud.dev v0.36.0 h1:q5zoXux4xkOZP473e1EZbG8Gq9f0vlg1VNH5Du/ybus=
gocloud.dev v0.36.0/go.mod h1:bLxah6JQVKBaIxzsr5BQLYB4IYdWHkMZdzCXlo6F0gg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@ -522,8 +522,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.156.0 h1:yloYcGbBtVYjLKQe4enCunxvwn3s2w/XPrrhVf6MsvQ=
google.golang.org/api v0.156.0/go.mod h1:bUSmn4KFO0Q+69zo9CNIDp4Psi6BqM0np0CbzKRSiSY=
google.golang.org/api v0.157.0 h1:ORAeqmbrrozeyw5NjnMxh7peHO0UzV4wWYSwZeCUb20=
google.golang.org/api v0.157.0/go.mod h1:+z4v4ufbZ1WEpld6yMGHyggs+PmAHiaLNj5ytP3N01g=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=

View file

@ -475,24 +475,6 @@ type ConnectionTransfer struct {
DLSize int64 `json:"-"`
}
func (t *ConnectionTransfer) getConnectionTransferAsString() string {
result := ""
switch t.OperationType {
case operationUpload:
result += "UL "
case operationDownload:
result += "DL "
}
result += fmt.Sprintf("%q ", t.VirtualPath)
if t.Size > 0 {
elapsed := time.Since(util.GetTimeFromMsecSinceEpoch(t.StartTime))
speed := float64(t.Size) / float64(util.GetTimeAsMsSinceEpoch(time.Now())-t.StartTime)
result += fmt.Sprintf("Size: %s Elapsed: %s Speed: \"%.1f KB/s\"", util.ByteCountIEC(t.Size),
util.GetDurationAsString(elapsed), speed)
}
return result
}
// MetadataConfig defines how to handle metadata for cloud storage backends
type MetadataConfig struct {
// If not zero the metadata will be read before downloads and will be
@ -1254,6 +1236,7 @@ func (conns *ActiveConnections) GetStats(role string) []ConnectionStatus {
RemoteAddress: c.GetRemoteAddress(),
ConnectionTime: util.GetTimeAsMsSinceEpoch(c.GetConnectionTime()),
LastActivity: util.GetTimeAsMsSinceEpoch(c.GetLastActivity()),
CurrentTime: util.GetTimeAsMsSinceEpoch(time.Now()),
Protocol: c.GetProtocol(),
Command: c.GetCommand(),
Transfers: c.GetTransfers(),
@ -1279,6 +1262,8 @@ type ConnectionStatus struct {
ConnectionTime int64 `json:"connection_time"`
// Last activity as unix timestamp in milliseconds
LastActivity int64 `json:"last_activity"`
// Current time as unix timestamp in milliseconds
CurrentTime int64 `json:"current_time"`
// Protocol for this connection
Protocol string `json:"protocol"`
// active uploads/downloads
@ -1289,45 +1274,6 @@ type ConnectionStatus struct {
Node string `json:"node,omitempty"`
}
// GetConnectionDuration returns the connection duration as string
func (c *ConnectionStatus) GetConnectionDuration() string {
elapsed := time.Since(util.GetTimeFromMsecSinceEpoch(c.ConnectionTime))
return util.GetDurationAsString(elapsed)
}
// GetConnectionInfo returns connection info.
// Protocol,Client Version and RemoteAddress are returned.
func (c *ConnectionStatus) GetConnectionInfo() string {
var result strings.Builder
result.WriteString(fmt.Sprintf("%v. Client: %q From: %q", c.Protocol, c.ClientVersion, c.RemoteAddress))
if c.Command == "" {
return result.String()
}
switch c.Protocol {
case ProtocolSSH, ProtocolFTP:
result.WriteString(fmt.Sprintf(". Command: %q", c.Command))
case ProtocolWebDAV:
result.WriteString(fmt.Sprintf(". Method: %q", c.Command))
}
return result.String()
}
// GetTransfersAsString returns the active transfers as string
func (c *ConnectionStatus) GetTransfersAsString() string {
result := ""
for _, t := range c.Transfers {
if result != "" {
result += ". "
}
result += t.getConnectionTransferAsString()
}
return result
}
// ActiveQuotaScan defines an active quota scan for a user
type ActiveQuotaScan struct {
// Username to which the quota scan refers

View file

@ -23,7 +23,6 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
@ -892,23 +891,10 @@ func TestConnectionStatus(t *testing.T) {
assert.Len(t, stats, 3)
for _, stat := range stats {
assert.Equal(t, stat.Username, username)
assert.True(t, strings.HasPrefix(stat.GetConnectionInfo(), stat.Protocol))
assert.True(t, strings.HasPrefix(stat.GetConnectionDuration(), "00:"))
if stat.ConnectionID == "SFTP_id1" {
assert.Len(t, stat.Transfers, 2)
assert.Greater(t, len(stat.GetTransfersAsString()), 0)
for _, tr := range stat.Transfers {
if tr.OperationType == operationDownload {
assert.True(t, strings.HasPrefix(tr.getConnectionTransferAsString(), "DL"))
} else if tr.OperationType == operationUpload {
assert.True(t, strings.HasPrefix(tr.getConnectionTransferAsString(), "UL"))
}
}
} else if stat.ConnectionID == "DAV_id3" {
assert.Len(t, stat.Transfers, 1)
assert.Greater(t, len(stat.GetTransfersAsString()), 0)
} else {
assert.Equal(t, 0, len(stat.GetTransfersAsString()))
}
}

View file

@ -1710,6 +1710,8 @@ func (s *httpdServer) setupWebAdminRoutes() {
Delete(webGroupPath+"/{name}", deleteGroup)
router.With(s.checkPerm(dataprovider.PermAdminViewConnections), s.refreshCookie).
Get(webConnectionsPath, s.handleWebGetConnections)
router.With(s.checkPerm(dataprovider.PermAdminViewConnections), s.refreshCookie).
Get(webConnectionsPath+jsonAPISuffix, getActiveConnections)
router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
Get(webFoldersPath, s.handleWebGetFolders)
router.With(s.checkPerm(dataprovider.PermAdminManageFolders), compressor.Handler, s.refreshCookie).

View file

@ -99,7 +99,6 @@ const (
templateMFA = "mfa.html"
templateSetup = "adminsetup.html"
pageAdminsTitle = "Admins"
pageConnectionsTitle = "Connections"
pageStatusTitle = "Status"
pageEventRulesTitle = "Event rules"
pageEventActionsTitle = "Event actions"
@ -185,11 +184,6 @@ type eventActionsPage struct {
Actions []dataprovider.BaseEventAction
}
type connectionsPage struct {
basePage
Connections []common.ConnectionStatus
}
type statusPage struct {
basePage
Status *ServicesStatus
@ -412,7 +406,7 @@ func loadAdminTemplates(templatesPath string) {
filepath.Join(templatesPath, templateCommonDir, templateChangePwd),
}
connectionsPaths := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateConnections),
}
@ -3336,12 +3330,8 @@ func (s *httpdServer) handleWebGetConnections(w http.ResponseWriter, r *http.Req
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
}
connectionStats := common.Connections.GetStats(claims.Role)
connectionStats = append(connectionStats, getNodesConnections(claims.Username, claims.Role)...)
data := connectionsPage{
basePage: s.getBasePageData(pageConnectionsTitle, webConnectionsPath, r),
Connections: connectionStats,
}
data := s.getBasePageData(util.I18nSessionsTitle, webConnectionsPath, r)
renderAdminTemplate(w, templateConnections, data)
}

View file

@ -1077,19 +1077,6 @@ func TestCommandGetFsError(t *testing.T) {
assert.Error(t, err)
}
func TestGetConnectionInfo(t *testing.T) {
c := common.ConnectionStatus{
Username: "test_user",
ConnectionID: "123",
ClientVersion: "client",
RemoteAddress: "127.0.0.1:1234",
Protocol: common.ProtocolSSH,
Command: "sha1sum /test_file_ftp.dat",
}
info := c.GetConnectionInfo()
assert.Contains(t, info, "sha1sum /test_file_ftp.dat")
}
func TestSCPFileMode(t *testing.T) {
mode := getFileModeAsString(0, true)
assert.Equal(t, "0755", mode)
@ -1832,42 +1819,6 @@ func TestTransferFailingReader(t *testing.T) {
assert.Len(t, connection.GetTransfers(), 0)
}
func TestConnectionStatusStruct(t *testing.T) {
var transfers []common.ConnectionTransfer
transferUL := common.ConnectionTransfer{
OperationType: "upload",
StartTime: util.GetTimeAsMsSinceEpoch(time.Now()),
Size: 123,
VirtualPath: "/test.upload",
}
transferDL := common.ConnectionTransfer{
OperationType: "download",
StartTime: util.GetTimeAsMsSinceEpoch(time.Now()),
Size: 123,
VirtualPath: "/test.download",
}
transfers = append(transfers, transferUL)
transfers = append(transfers, transferDL)
c := common.ConnectionStatus{
Username: "test",
ConnectionID: "123",
ClientVersion: "fakeClient-1.0.0",
RemoteAddress: "127.0.0.1:1234",
ConnectionTime: util.GetTimeAsMsSinceEpoch(time.Now()),
LastActivity: util.GetTimeAsMsSinceEpoch(time.Now()),
Protocol: "SFTP",
Transfers: transfers,
}
durationString := c.GetConnectionDuration()
assert.NotEqual(t, 0, len(durationString))
transfersString := c.GetTransfersAsString()
assert.NotEqual(t, 0, len(transfersString))
connInfo := c.GetConnectionInfo()
assert.NotEqual(t, 0, len(connInfo))
}
func TestConfigsFromProvider(t *testing.T) {
err := dataprovider.UpdateConfigs(nil, "", "", "")
assert.NoError(t, err)

View file

@ -58,6 +58,7 @@ const (
I18nConfigsTitle = "title.configs"
I18nOAuth2Title = "title.oauth2_success"
I18nOAuth2ErrorTitle = "title.oauth2_error"
I18nSessionsTitle = "title.connections"
I18nErrorSetupInstallCode = "setup.install_code_mismatch"
I18nInvalidAuth = "general.invalid_auth_request"
I18nError429Message = "general.error429"

View file

@ -31,7 +31,7 @@
"users": "Users",
"groups": "Groups",
"folders": "Virtual folders",
"connections": "Active sessions",
"connections": "Active connections",
"event_manager": "Event Manager",
"event_rules": "Rules",
"event_actions": "Actions",
@ -219,7 +219,9 @@
"duplicated_name": "The specified name already exists",
"permissions_required": "Permissions are required",
"backup_ok": "Backup successfully restored",
"configs_saved": "Configurations has been successfully updated"
"configs_saved": "Configurations has been successfully updated",
"protocol": "Protocol",
"refresh": "Refresh"
},
"fs": {
"view_file": "View file \"{{- path}}\"",
@ -670,5 +672,19 @@
},
"admin": {
"role_permissions": "A role admin cannot have the following permissions: {{val}}"
},
"connections": {
"view_manage": "View and manage connections",
"started": "Started",
"remote_address": "Remote address",
"last_activity": "Last activity",
"disconnect_confirm_btn": "Yes, disconnect",
"disconnect_confirm": "Do you want to disconnect the selected connection? This action is irreversible",
"disconnect_ko": "Unable to disconnect the selected connection",
"upload": "UL: \"{{- path}}\"",
"download": "DL: \"{{- path}}\"",
"upload_info": "$t(connections.upload). Size: {{- size}}. Speed: {{- speed}}",
"download_info": "$t(connections.download). Size: {{- size}}. Speed: {{- speed}}",
"client": "Client: {{- val}}"
}
}

View file

@ -31,7 +31,7 @@
"users": "Utenti",
"groups": "Gruppi",
"folders": "Cartelle virtuali",
"connections": "Sessioni attive",
"connections": "Connessioni attive",
"event_manager": "Gestione eventi",
"event_rules": "Regole",
"event_actions": "Azioni",
@ -219,7 +219,9 @@
"duplicated_name": "Il nome specificato esiste già",
"permissions_required": "I permessi sono obbligatori",
"backup_ok": "Backup ripristinato correttamente",
"configs_saved": "Configurazioni aggiornate"
"configs_saved": "Configurazioni aggiornate",
"protocol": "Protocollo",
"refresh": "Aggiorna"
},
"fs": {
"view_file": "Visualizza file \"{{- path}}\"",
@ -670,5 +672,19 @@
},
"admin": {
"role_permissions": "Un amministratore di ruolo non può avere le seguenti autorizzazioni: {{val}}"
},
"connections": {
"view_manage": "Visualizza e gestisci connessioni attive",
"started": "Iniziata",
"remote_address": "Indirizzo remoto",
"last_activity": "Ultima attività",
"disconnect_confirm_btn": "Si, disconnetti",
"disconnect_confirm": "Vuoi disconnettere la connessione selezionata? Questa azione è irreversibile",
"disconnect_ko": "Impossibile disconnettere la connessione selezionata",
"upload": "UL: \"{{- path}}\"",
"download": "DL: \"{{- path}}\"",
"upload_info": "$t(connections.upload). Dimensione: {{- size}}. Velocità: {{- speed}}",
"download_info": "$t(connections.download). Dimensione: {{- size}}. Velocità: {{- speed}}",
"client": "Client: {{- val}}"
}
}

View file

@ -81,6 +81,11 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
+' '+(e?'KMGTPEZY'[--e]+'iB':'Bytes')
}
function humanizeSpeed(a,b,c,d,e){
return (b=Math,c=b.log,d=1024,e=c(a)/c(d)|0,a/b.pow(d,e)).toFixed(1)
+' '+(e?'KMGTPEZY'[--e]+'B/s':'Bytes/s')
}
function initRepeaterItems() {
let repeaterDeleteButtons = document.querySelectorAll('[data-repeater-delete]');
let repeaterCreateButtons = document.querySelectorAll('[data-repeater-create]');

View file

@ -1,221 +1,376 @@
<!--
Copyright (C) 2019 Nicola Murino
Copyright (C) 2024 Nicola Murino
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, version 3.
This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
https://keenthemes.com/products/templates-mega-bundle
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
KeenThemes HTML/CSS/JS components are allowed for use only within the
SFTPGo product and restricted to be used in a resealable HTML template
that can compete with KeenThemes products anyhow.
This WebUI is allowed for use only within the SFTPGo product and
therefore cannot be used in derivative works/products without an
explicit grant from the SFTPGo Team (support@sftpgo.com).
-->
{{template "base" .}}
{{define "title"}}{{.Title}}{{end}}
{{- define "extra_css"}}
<link href="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.css" rel="stylesheet" type="text/css"/>
{{- end}}
{{define "extra_css"}}
<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
{{end}}
{{define "page_body"}}
<div id="errorMsg" class="alert alert-warning fade show" style="display: none;" role="alert">
<span id="errorTxt"></span>
<button type="button" class="close" aria-label="Close" onclick="dismissErrorMsg();">
<span aria-hidden="true">&times;</span>
</button>
</div>
<script type="text/javascript">
function dismissErrorMsg(){
$('#errorMsg').hide();
}
</script>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">View and manage connections</h6>
{{- define "page_body"}}
<div class="card shadow-sm">
<div class="card-header bg-light">
<h3 data-i18n="connections.view_manage" class="card-title section-title">View and manage connections</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
<div id="card_body" class="card-body">
<div id="loader" class="align-items-center text-center my-10">
<span class="spinner-border w-15px h-15px text-muted align-middle me-2"></span>
<span data-i18n="general.loading" class="text-gray-700">Loading...</span>
</div>
<div id="card_content" class="d-none">
<div class="d-flex flex-stack flex-wrap mb-5">
<div class="d-flex align-items-center position-relative my-2">
<i class="ki-solid ki-magnifier fs-1 position-absolute ms-6"></i>
<input name="search" data-i18n="[placeholder]general.search" type="text" data-table-filter="search"
class="form-control rounded-1 w-250px ps-15 me-5" placeholder="Search" />
</div>
<div class="d-flex justify-content-end my-2" data-table-toolbar="base">
<a href="{{.ConnectionsURL}}" class="btn btn-primary">
<i class="ki-solid ki-arrows-circle fs-2"></i>
<span data-i18n="general.refresh">Refresh</span>
</a>
</div>
</div>
<table id="dataTable" class="table align-middle table-row-dashed fs-6 gy-5">
<thead>
<tr>
<tr class="text-start text-muted fw-bold fs-6 gs-0">
<th>ID</th>
<th>Node</th>
<th>Username</th>
<th>Time</th>
<th>Info</th>
<th>Transfers</th>
<th data-i18n="login.username">Username</th>
<th data-i18n="connections.started">Started</th>
<th data-i18n="connections.remote_address">Remote address</th>
<th data-i18n="general.protocol">Protocol</th>
<th data-i18n="connections.last_activity">Last activity</th>
<th data-i18n="general.info">Info</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Connections}}
<tr>
<td>{{.ConnectionID}}</td>
<td>{{.Node}}</td>
<td>{{.Username}}</td>
<td>{{.GetConnectionDuration}}</td>
<td>{{.GetConnectionInfo}}</td>
<td>{{.GetTransfersAsString}}</td>
</tr>
{{end}}
</tbody>
<tbody id="table_body" class="text-gray-800 fw-semibold"></tbody>
</table>
</div>
</div>
</div>
{{end}}
{{- end}}
{{define "dialog"}}
<div class="modal fade" id="disconnectModal" tabindex="-1" role="dialog" aria-labelledby="disconnectModalLabel"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="disconnectModalLabel">
Confirmation required
</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">Do you want to close the selected connection?</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-dismiss="modal">
Cancel
</button>
<a class="btn btn-warning" href="#" onclick="disconnectAction()">
Disconnect
</a>
</div>
</div>
</div>
</div>
{{end}}
{{- define "extra_js"}}
<script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.js"></script>
<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.colVis.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
<script type="text/javascript">
function disconnectAction() {
let table = $('#dataTable').DataTable();
table.button('disconnect:name').enable(false);
let selectedData = table.row({ selected: true }).data()
let connectionID = selectedData[0];
let nodeID = selectedData[1];
let path = '{{.ConnectionsURL}}' + "/" + fixedEncodeURIComponent(connectionID)+"?node="+encodeURIComponent(nodeID);
$('#disconnectModal').modal('hide');
$('#errorMsg').hide();
$.ajax({
url: path,
type: 'DELETE',
dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
success: function (result) {
window.location.href = '{{.ConnectionsURL}}';
},
error: function ($xhr, textStatus, errorThrown) {
var txt = "Failed to close the selected connection";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
function disconnectAction(connectionID, node) {
ModalAlert.fire({
text: $.t('connections.disconnect_confirm'),
icon: "warning",
confirmButtonText: $.t('connections.disconnect_confirm_btn'),
cancelButtonText: $.t('general.cancel'),
customClass: {
confirmButton: "btn btn-danger",
cancelButton: 'btn btn-secondary'
}
}).then((result) => {
if (result.isConfirmed){
$('#loading_message').text("");
KTApp.showPageLoading();
let path = '{{.ConnectionsURL}}' + "/" + encodeURIComponent(connectionID);
if (node) {
path+="?node="+ encodeURIComponent(node);
}
$('#errorTxt').text(txt);
$('#errorMsg').show();
axios.delete(path, {
timeout: 15000,
headers: {
'X-CSRF-TOKEN': '{{.CSRFToken}}'
},
validateStatus: function (status) {
return status == 200;
}
}).then(function(response){
setTimeout(function() {
location.reload();
},250);
}).catch(function(error){
KTApp.hidePageLoading();
ModalAlert.fire({
text: $.t('connections.disconnect_ko'),
icon: "warning",
confirmButtonText: $.t('general.ok'),
customClass: {
confirmButton: "btn btn-primary"
}
});
});
}
});
}
$(document).ready(function () {
$.fn.dataTable.ext.buttons.disconnect = {
text: 'Disconnect',
name: 'disconnect',
action: function (e, dt, node, config) {
$('#disconnectModal').modal('show');
},
enabled: false
};
var datatable = function(){
var dt;
$.fn.dataTable.ext.buttons.refresh = {
text: '<i class="fas fa-sync-alt"></i>',
name: 'refresh',
titleAttr: "Refresh",
action: function (e, dt, node, config) {
location.reload();
}
};
var table = $('#dataTable').DataTable({
"select": {
"style": "single",
"blurable": true
},
"buttons": [
{
"text": "Column visibility",
"extend": "colvis",
"columns": ":not(.noVis)"
}
],
"lengthChange": true,
"columnDefs": [
{
"targets": [0, 1],
"visible": false,
"searchable": false,
"className": "noVis"
var initDatatable = function () {
$('#errorMsg').addClass("d-none");
dt = $('#dataTable').DataTable({
ajax: {
url: "{{.ConnectionsURL}}/json",
dataSrc: "",
error: function ($xhr, textStatus, errorThrown) {
$(".dataTables_processing").hide();
let txt = "";
if ($xhr) {
let json = $xhr.responseJSON;
if (json) {
if (json.message){
txt = json.message;
}
}
}
if (!txt){
txt = "general.error500";
}
setI18NData($('#errorTxt'), txt);
$('#errorMsg').removeClass("d-none");
}
},
{
"targets": [2],
"className": "noVis"
columns: [
{
data: "connection_id",
visible: false,
searchable: false,
orderable: false,
render: function(data, type, row) {
if (type === 'display') {
return escapeHTML(data);
}
return data;
}
},
{
data: "node",
visible: false,
searchable: false,
orderable: false,
defaultContent: "",
render: function(data, type, row) {
if (type === 'display') {
return escapeHTML(data);
}
return data;
}
},
{
data: "username",
defaultContent: "",
render: function(data, type, row) {
if (type === 'display') {
return escapeHTML(data);
}
return data;
}
},
{
data: "connection_time",
searchable: false,
defaultContent: 0,
render: function(data, type, row) {
if (type === 'display') {
if (data > 0){
return $.t('general.datetime', {
val: parseInt(data, 10),
formatParams: {
val: { year: '2-digit', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' },
}
});
}
return ""
}
return data;
}
},
{
data: "remote_address",
defaultContent: "",
render: function(data, type, row) {
if (type === 'display') {
return escapeHTML(data);
}
return data;
}
},
{
data: "protocol",
defaultContent: "",
render: function(data, type, row) {
if (type === 'display') {
return escapeHTML(data);
}
return data;
}
},
{
data: "last_activity",
searchable: false,
defaultContent: 0,
render: function(data, type, row) {
if (type === 'display') {
if (data > 0){
return $.t('general.datetime', {
val: parseInt(data, 10),
formatParams: {
val: { year: '2-digit', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' },
}
});
}
return ""
}
return data;
}
},
{
data: "active_transfers",
searchable: false,
orderable: false,
render: function (data, type, row) {
if (type === 'display') {
let result = "";
if (row.active_transfers && row.active_transfers.length > 0){
let transfer = row.active_transfers[0];
let path = escapeHTML(transfer.path);
let elapsed = row.current_time - transfer.start_time;
if (elapsed > 0 && transfer.size > 0){
let speed = (transfer.size*1.0) / (elapsed/1000.0);
if (transfer.operation_type === 'upload'){
result = $.t('connections.upload_info', {path: path, size: fileSizeIEC(transfer.size), speed: humanizeSpeed(speed)});
} else {
result = $.t('connections.download_info', {path: path, size: fileSizeIEC(transfer.size), speed: humanizeSpeed(speed)});
}
} else {
if (transfer.operation_type === 'upload'){
result = $.t('connections.upload', {path: path});
} else {
result = $.t('connections.download', {path: path});
}
}
}
if (row.client_version){
if (result){
result+= ". ";
}
result+= $.t('connections.client', {val: escapeHTML(row.client_version)});
}
return result;
}
return "";
}
},
{
data: "",
searchable: false,
orderable: false,
className: 'text-end',
render: function (data, type, row) {
if (type === 'display') {
//{{- if .LoggedUser.HasPermission "close_conns"}}
return `<div class="d-flex justify-content-end">
<div class="ms-2">
<a href="#" class="btn btn-sm btn-icon btn-light-danger" data-table-action="close_conn">
<i class="ki-solid ki-cross fs-1"></i>
</a>
</div>
</div>`;
//{{- end}}
}
return "";
}
},
],
deferRender: true,
stateSave: true,
stateDuration: 0,
colReorder: {
enable: true
},
stateLoadParams: function (settings, data) {
if (data.search.search){
const filterSearch = document.querySelector('[data-table-filter="search"]');
filterSearch.value = data.search.search;
}
},
language: {
info: $.t('datatable.info'),
infoEmpty: $.t('datatable.info_empty'),
infoFiltered: $.t('datatable.info_filtered'),
loadingRecords: "",
processing: $.t('datatable.processing'),
zeroRecords: "",
emptyTable: $.t('datatable.no_records')
},
order: [[0, 'asc']],
initComplete: function(settings, json) {
$('#loader').addClass("d-none");
$('#card_content').removeClass("d-none");
let api = $.fn.dataTable.Api(settings);
api.columns.adjust().draw("page");
drawAction();
}
],
"scrollX": false,
"scrollY": false,
"responsive": true,
"language": {
"emptyTable": "No user connected"
},
"order": [[2, 'asc']]
});
});
new $.fn.dataTable.FixedHeader( table );
dt.on('draw', drawAction);
dt.on('column-reorder', function(e, settings, details){
drawAction();
});
}
table.button().add(0, 'refresh');
//table.button().add(0,'pageLength');
function drawAction() {
KTMenu.createInstances();
handleRowActions();
$('#table_body').localize();
}
{{if .LoggedAdmin.HasPermission "close_conns"}}
table.button().add(0,'disconnect');
var handleDatatableActions = function () {
const filterSearch = $(document.querySelector('[data-table-filter="search"]'));
filterSearch.off("keyup");
filterSearch.on('keyup', function (e) {
dt.rows().deselect();
dt.search(e.target.value, true, false).draw();
});
}
table.on('select deselect', function () {
var selectedRows = table.rows({ selected: true }).count();
table.button('disconnect:name').enable(selectedRows == 1);
});
{{end}}
table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
function handleRowActions() {
const closeButtons = document.querySelectorAll('[data-table-action="close_conn"]');
closeButtons.forEach(d => {
let el = $(d);
el.off("click");
el.on("click", function(e){
e.preventDefault();
const parent = e.target.closest('tr');
let data = dt.row(parent).data();
disconnectAction(data.connection_id, data.node);
});
});
}
return {
init: function () {
initDatatable();
handleDatatableActions();
}
}
}();
$(document).on("i18nshow", function(){
datatable.init();
});
</script>
{{end}}
{{- end}}

View file

@ -77,7 +77,6 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
{{- end}}
</div>
</div>
</div>
<table id="dataTable" class="table align-middle table-row-dashed fs-6 gy-5">
<thead>
@ -284,7 +283,6 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
<i class="ki-duotone ki-down fs-5 ms-1 rotate-180"></i>
</button>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-700 menu-state-bg-light-primary fw-semibold fs-6 w-200px py-4" data-kt-menu="true">`;
//{{- if .LoggedUser.HasPermission "manage_folders"}}
numActions++;
actions+=`<div class="menu-item px-3">