Commit 0c80cd5f authored by Nicolas Pernoud's avatar Nicolas Pernoud
Browse files

feat: added system information tab

parent dfb73c48
Pipeline #4478 passed with stages
in 2 minutes and 32 seconds
......@@ -12,6 +12,7 @@ import (
"github.com/nicolaspernoud/vestibule/pkg/davserver"
"github.com/nicolaspernoud/vestibule/pkg/middlewares"
"github.com/nicolaspernoud/vestibule/pkg/onlyoffice"
"github.com/nicolaspernoud/vestibule/pkg/sysinfo"
"golang.org/x/crypto/acme/autocert"
"github.com/nicolaspernoud/vestibule/pkg/common"
......@@ -77,6 +78,7 @@ func CreateRootMux(port int, appsFile string, davsFile string, staticDir string)
adminMux.HandleFunc("/apps/", appServer.ProcessApps)
adminMux.HandleFunc("/davs/", davServer.ProcessDavs)
adminMux.HandleFunc("/users/", auth.ProcessUsers)
adminMux.HandleFunc("/sysinfo/", sysinfo.GetInfo)
mainMux.Handle("/api/admin/", http.StripPrefix("/api/admin", auth.ValidateAuthMiddleware(adminMux, []string{os.Getenv("ADMIN_ROLE")}, true)))
// Serve static files falling back to serving index.html
mainMux.Handle("/", middlewares.NoCache(http.FileServer(&common.FallBackWrapper{Assets: http.Dir(staticDir)})))
......
......@@ -278,6 +278,8 @@ func createAdminTests(t *testing.T) func(wg *sync.WaitGroup) {
do("PUT", "admindav.vestibule.io/mydata/test2.txt", xsrfHeader, "This is a write test", 201, "")
// Try to delete a resource on an authorized dav (must pass)
do("DELETE", "admindav.vestibule.io/mydata/test2.txt", xsrfHeader, "", 204, "")
// Try to get the system information (must pass)
do("GET", "/api/admin/sysinfo/", xsrfHeader, "", 200, `{"uptime"`)
}
// Try to login (must pass)
do("GET", "/OAuth2Login", noH, "", 200, "<!DOCTYPE html>")
......
......@@ -13,10 +13,6 @@ import (
"github.com/nicolaspernoud/vestibule/pkg/du"
)
const (
gB = 1 << (10 * 3)
)
// Dav represents a webdav file service
type Dav struct {
ID int `json:"id"`
......@@ -76,8 +72,8 @@ func (s *Server) SendDavs(w http.ResponseWriter, req *http.Request) {
for i, dav := range davs {
usage, err := du.NewDiskUsage(dav.Root)
if err == nil {
dav.UsedGB = usage.Used() / gB
dav.TotalGB = usage.Size() / gB
dav.UsedGB = usage.Used() / du.GB
dav.TotalGB = usage.Size() / du.GB
}
// Do not leak encryption passphrase to non admins users
if !user.IsAdmin {
......
......@@ -9,6 +9,11 @@ import (
"golang.org/x/sys/unix"
)
const (
// GB is one GB
GB = 1 << (10 * 3)
)
//DiskUsage is an object holding a disk usage
type DiskUsage struct {
stat *unix.Statfs_t
......
......@@ -7,6 +7,11 @@ import (
"unsafe"
)
const (
// GB is one GB
GB = 1 << (10 * 3)
)
//DiskUsage is an object holding the disk usage
type DiskUsage struct {
freeBytes int64
......
package sysinfo
import (
"encoding/json"
"net/http"
"time"
)
// SysInfo represents global sustem information
type SysInfo struct {
Uptime time.Duration `json:"uptime,omitempty"` // time since boot
Load float64 `json:"load,omitempty"` // 1 minute load average divided by number of CPU Cores
TotalRAM uint64 `json:"totalram,omitempty"` // total usable main memory size [kB]
FreeRAM uint64 `json:"freeram,omitempty"` // available memory size [kB]
UsedGB uint64 `json:"usedgb,omitempty"` // Used GB of HDD
TotalGB uint64 `json:"totalgb,omitempty"` // Total GB of HDD
}
// GetInfo returns the system information
func GetInfo(w http.ResponseWriter, r *http.Request) {
info, err := Info()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(info)
}
package sysinfo
import (
"net/http"
"testing"
"github.com/nicolaspernoud/vestibule/pkg/tester"
)
func TestGetInfo(t *testing.T) {
handler := http.HandlerFunc(GetInfo)
tester.DoRequestOnHandler(t, handler, "GET", "/", tester.Header{Key: "", Value: ""}, "", http.StatusOK, `{"uptime"`)
}
// +build !windows
package sysinfo
import (
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/nicolaspernoud/vestibule/pkg/du"
"golang.org/x/sys/unix"
)
// float64(1<<SI_LOAD_SHIFT) == 65536.0
const scale = 65536.0
var mtx = new(sync.RWMutex)
// Info returns a complete system information object on unix
func Info() (*SysInfo, error) {
mtx.Lock()
defer mtx.Unlock()
sysinfo := &SysInfo{}
// Get loads and memory usage
rawsysinfo := &unix.Sysinfo_t{}
if err := unix.Sysinfo(rawsysinfo); err != nil {
return nil, err
}
// Get number of cores
cores := float64(runtime.NumCPU())
// Get executable path
ex, err := os.Executable()
if err != nil {
return nil, err
}
exPath := filepath.Dir(ex)
usage, err := du.NewDiskUsage(exPath)
if err == nil {
sysinfo.UsedGB = usage.Used() / du.GB
sysinfo.TotalGB = usage.Size() / du.GB
}
sysinfo.Uptime = time.Duration(rawsysinfo.Uptime) * time.Second
sysinfo.Load = (float64(rawsysinfo.Loads[0]) / scale) / cores
unit := uint64(rawsysinfo.Unit) * 1024 // kB
sysinfo.TotalRAM = uint64(rawsysinfo.Totalram) / unit
sysinfo.FreeRAM = uint64(rawsysinfo.Freeram) / unit
return sysinfo, nil
}
package sysinfo
import (
"os"
"path/filepath"
"github.com/nicolaspernoud/vestibule/pkg/du"
)
// Info returns only the disk usage on windows
func Info() (*SysInfo, error) {
sysinfo := &SysInfo{}
// Get executable path
ex, err := os.Executable()
if err != nil {
return nil, err
}
exPath := filepath.Dir(ex)
usage, err := du.NewDiskUsage(exPath)
if err == nil {
sysinfo.UsedGB = usage.Used() / du.GB
sysinfo.TotalGB = usage.Size() / du.GB
}
return sysinfo, nil
}
......@@ -5,6 +5,7 @@ import { Icons } from "/services/common/icons.js";
import { AnimateCSS } from "/services/common/common.js";
import { Explorer } from "./explorer.js";
import { Delete } from "/services/common/delete.js";
import { GetColor } from "../sysinfo/sysinfo.js";
// DOM elements
let mountpoint;
......@@ -134,17 +135,6 @@ export async function mount(where) {
function davTemplate(dav) {
cleanDav(dav);
const du = dav.usedgb / dav.totalgb;
let duType;
switch (true) {
case du >= 0.9:
duType = "danger";
break;
case du >= 0.75 && du < 0.9:
duType = "warning";
break;
default:
duType = "success";
}
const free = dav.totalgb - dav.usedgb;
return /* HTML */ `
<div id="davs-dav-${dav.id}" class="card icon-card">
......@@ -172,7 +162,7 @@ function davTemplate(dav) {
${user.isAdmin ? '<a class="dropdown-item has-text-danger" id="davs-dav-delete-' + dav.id + '"><i class="fas fa-trash-alt"></i><strong> Delete</strong></a>' : ""}
<hr class="dropdown-divider" />
<div class="dropdown-item">
<p><progress class="progress is-${duType}" value="${dav.usedgb}" max="${dav.totalgb}"></progress>${dav.usedgb !== undefined ? free + " GB free" : ""}</p>
<p><progress class="progress is-${GetColor(du)}" value="${dav.usedgb}" max="${dav.totalgb}"></progress>${dav.usedgb !== undefined ? free + " GB free" : ""}</p>
<hr class="dropdown-divider" />
<p><strong>${dav.host}</strong></p>
<p>Serves ${dav.root} directory, with ${dav.writable ? "read/write" : "read only"} access</p>
......
......@@ -56,6 +56,7 @@ export async function CreateMenu() {
${user.isAdmin
? /* HTML */ `
<a class="navbar-item" href="#users"><i class="navbar-menu-icon fas fa-users"></i>Users</a>
<a class="navbar-item" href="#sysinfo"><i class="navbar-menu-icon fas fa-stethoscope"></i>System information</a>
`
: ""}
`
......
// Imports
import * as Messages from "/services/messages/messages.js";
import { RandomString } from "/services/common/common.js";
import * as Auth from "/services/auth/auth.js";
export async function mount(where) {
const infoComponent = new Sysinfo();
await infoComponent.mount(where);
return setInterval(() => {
infoComponent.update();
}, 1000);
}
class Sysinfo {
constructor() {
// Random id seed
this.prefix = RandomString(8);
}
async mount(mountpoint) {
const mnt = document.getElementById(mountpoint);
mnt.innerHTML = /* HTML */ `
<div class="card">
<div id="${this.prefix}-sysinfo" class="card-content"></div>
</div>
`;
await this.update();
}
async update() {
const content = document.getElementById(`${this.prefix}-sysinfo`);
this.user = await Auth.GetUser();
try {
const response = await fetch("/api/admin/sysinfo/", {
method: "GET",
headers: new Headers({
"XSRF-Token": this.user.xsrftoken
}),
credentials: "include"
});
if (response.status !== 200) {
throw new Error(`System information could not be fetched (status ${response.status})`);
}
const info = await response.json();
content.innerHTML = this.computeTemplate(info);
} catch (e) {
Messages.Show("is-warning", e.message);
console.error(e);
}
}
computeTemplate(info) {
const mu = (info.totalram - info.freeram) / info.totalram;
const du = info.usedgb / info.totalgb;
const dfree = info.totalgb - info.usedgb;
return /* HTML */ `
<div class="content">
${info.load !== undefined
? /* HTML */ `
<h1>CPU usage</h1>
<p>
<progress class="progress is-${GetColor(info.load)}" value="${info.load}" max="1"></progress>
${(info.load * 100).toFixed(0)} %
</p>
`
: ""}
${info.freeram !== undefined
? /* HTML */ `
<h1>Memory usage</h1>
<p>
<progress class="progress is-${GetColor(mu)}" value="${info.totalram - info.freeram}" max="${info.totalram}"></progress>
${(info.freeram / Math.pow(2, 20)).toFixed(2)} GB free
</p>
`
: ""}
${info.usedgb !== undefined
? /* HTML */ `
<h1>Disk usage</h1>
<p>
<progress class="progress is-${GetColor(du)}" value="${info.usedgb}" max="${info.totalgb}"></progress>
${dfree} GB free
</p>
`
: ""}
${info.uptime !== undefined
? /* HTML */ `
<h1>Uptime</h1>
<p>${secondsToDhms(info.uptime / Math.pow(10, 9))}</p>
`
: ""}
</div>
`;
}
}
function secondsToDhms(seconds) {
seconds = Number(seconds);
const d = Math.floor(seconds / (3600 * 24));
const h = Math.floor((seconds % (3600 * 24)) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const dDisplay = d > 0 ? d + (d == 1 ? " day, " : " days, ") : "";
const hDisplay = h > 0 ? h + (h == 1 ? " hour, " : " hours, ") : "";
const mDisplay = m > 0 ? m + (m == 1 ? " minute, " : " minutes, ") : "";
const sDisplay = s > 0 ? s + (s == 1 ? " second" : " seconds") : "";
return dDisplay + hDisplay + mDisplay + sDisplay;
}
export function GetColor(load) {
switch (true) {
case load >= 0.9:
return "danger";
case load >= 0.75 && load < 0.9:
return "warning";
default:
return "success";
}
}
......@@ -42,7 +42,7 @@
</div>
<div class="navbar-menu">
<div class="navbar-end">
<div class="navbar-item"><p>v4.2.1</p></div>
<div class="navbar-item"><p>v4.3.0</p></div>
</div>
</div>
</nav>
......
......@@ -2,11 +2,13 @@ import * as Apps from "/components/apps/apps.js";
import * as Davs from "/components/davs/davs.js";
import * as Users from "/components/users/users.js";
import * as Login from "/components/login/login.js";
import * as Sysinfo from "/components/sysinfo/sysinfo.js";
import * as Navbar from "/components/navbar/navbar.js";
import { AnimateCSS } from "/services/common/common.js";
const mountPoint = document.getElementById("main");
const spinner = document.getElementById("spinner");
let sysInfoInterval;
document.addEventListener("DOMContentLoaded", function() {
Navbar.mount("navbar");
......@@ -15,6 +17,7 @@ document.addEventListener("DOMContentLoaded", function() {
});
async function navigate() {
clearInterval(sysInfoInterval);
switch (location.hash) {
case "#apps":
load(mountPoint, async function() {
......@@ -36,6 +39,11 @@ async function navigate() {
await Login.mount("main");
});
break;
case "#sysinfo":
load(mountPoint, async function() {
sysInfoInterval = await Sysinfo.mount("main");
});
break;
default:
location.hash = "#apps";
break;
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment