feat(ui): add user settings and refine dashboard experience

This commit is contained in:
2026-06-20 18:42:14 -05:00
parent 2d46a9289c
commit 64beeba8be
22 changed files with 1255 additions and 409 deletions

View File

@@ -98,7 +98,11 @@ func New() (*App, error) {
protected.Get("/automations", handler.AutomationsPage)
protected.Get("/uptime", handler.UptimePage)
protected.Get("/settings", handler.SettingsPage)
protected.Get("/settings/user", handler.UserSettingsPage)
protected.Post("/settings/theme", handler.UpdateTheme)
protected.Post("/settings/user/theme", handler.UpdateUserTheme)
protected.Post("/settings/user/password", handler.UpdateUserPassword)
protected.Post("/settings/user/2fa/{action}", handler.UpdateUserOTP)
protected.Group(func(editor chi.Router) {
editor.Use(localmiddleware.RequireRole(models.RoleEditor))

View File

@@ -57,6 +57,7 @@ func migrate(ctx context.Context, database *sql.DB) error {
role TEXT NOT NULL,
otp_secret TEXT NOT NULL,
otp_enabled BOOLEAN NOT NULL DEFAULT 0,
theme_mode TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
);`,
@@ -185,6 +186,7 @@ func migrate(ctx context.Context, database *sql.DB) error {
alterStatements := []string{
`ALTER TABLE organizations ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'dark';`,
`ALTER TABLE users ADD COLUMN theme_mode TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN tag TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN package_manager TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN architecture TEXT NOT NULL DEFAULT '';`,

View File

@@ -54,10 +54,28 @@ type nodePageData struct {
}
type settingsData struct {
ThemeVariables template.CSS
CurrentTheme string
CurrentMode string
Runs []models.CommandRun
NodeCount int
GroupCount int
JobCount int
UserCount int
Logs []settingsCommandLog
}
type settingsCommandLog struct {
Target string
StartedAt time.Time
Status string
CommandText string
Output string
}
type userSettingsData struct {
CurrentMode string
OTPEnabled bool
OTPSecret string
OTPQRCode string
Message string
Error string
}
type jobsPageData struct {
@@ -70,6 +88,7 @@ type jobsPageData struct {
type uptimePageData struct {
Summary uptimeSummary
Chart uptimeChartData
Periods []uptimePeriodRow
Monitors []uptimeMonitorCard
Incidents []models.UptimeIncident
@@ -101,6 +120,26 @@ type uptimeMonitorCard struct {
RecentChecks []models.UptimeCheck
}
type uptimeChartPoint struct {
Label string
Availability float64
AvailabilityText string
UpCount int
DownCount int
TotalCount int
AvailabilityHeight int
X float64
Y float64
ShowLabel bool
}
type uptimeChartData struct {
Points []uptimeChartPoint
LabelsJSON template.JS
ValuesJSON template.JS
PointColorsJSON template.JS
}
func New(repo *services.Repository, auth *services.AuthService, sessions *services.SessionService, nodes *services.NodeService, renderer *views.Renderer, org models.Organization, baseURL string) *Handler {
return &Handler{
repo: repo,
@@ -748,17 +787,43 @@ func (h *Handler) CreateAutomation(w http.ResponseWriter, r *http.Request) {
func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) {
user := localmiddleware.CurrentUser(r)
org := h.currentOrganization(r.Context())
currentMode := normalizeMode(org.ThemeMode)
currentTheme := normalizeTheme(org.Theme, currentMode)
h.render(w, r, "settings", "Theme System", settingsData{
ThemeVariables: template.CSS(themePreview),
CurrentTheme: currentTheme,
CurrentMode: currentMode,
Runs: h.settingsRuns(r.Context()),
nodes, _ := h.nodes.ListNodes(r.Context(), h.org.ID)
groups, _ := h.repo.ListGroups(r.Context(), h.org.ID)
jobs, _ := h.nodes.ListAutomations(r.Context(), h.org.ID)
userCount, _ := h.repo.CountUsers(r.Context())
h.render(w, r, "settings", "Settings", settingsData{
NodeCount: len(nodes),
GroupCount: len(groups),
JobCount: len(jobs),
UserCount: userCount,
Logs: h.settingsLogs(r.Context()),
}, user)
}
func (h *Handler) UserSettingsPage(w http.ResponseWriter, r *http.Request) {
user := localmiddleware.CurrentUser(r)
if user == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
data := userSettingsData{
CurrentMode: effectiveThemeMode(user, h.currentOrganization(r.Context())),
OTPEnabled: user.OTPEnabled,
Message: strings.TrimSpace(r.URL.Query().Get("message")),
Error: strings.TrimSpace(r.URL.Query().Get("error")),
}
if !user.OTPEnabled {
uri := h.auth.BuildOTPURI(user.Email, user.OTPSecret)
png, _ := qrcode.Encode(uri, qrcode.Medium, 256)
data.OTPSecret = user.OTPSecret
data.OTPQRCode = "data:image/png;base64," + encodeBase64(png)
}
h.render(w, r, "user_settings", "User Settings", data, user)
}
func (h *Handler) UptimePage(w http.ResponseWriter, r *http.Request) {
user := localmiddleware.CurrentUser(r)
h.render(w, r, "uptime", "Uptime", h.uptimePageData(r.Context()), user)
@@ -800,6 +865,122 @@ func (h *Handler) UpdateTheme(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (h *Handler) UpdateUserTheme(w http.ResponseWriter, r *http.Request) {
user := localmiddleware.CurrentUser(r)
if user == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther)
return
}
mode := strings.TrimSpace(r.FormValue("mode"))
if !isAllowedMode(mode) {
http.Redirect(w, r, "/settings/user?error=Invalid+theme+mode", http.StatusSeeOther)
return
}
if err := h.repo.UpdateUserThemeMode(r.Context(), user.ID, mode); err != nil {
http.Redirect(w, r, "/settings/user?error=Failed+to+save+theme", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/settings/user?message=Theme+updated", http.StatusSeeOther)
}
func (h *Handler) UpdateUserPassword(w http.ResponseWriter, r *http.Request) {
user := localmiddleware.CurrentUser(r)
if user == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther)
return
}
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if h.auth.ComparePassword(user.PasswordHash, currentPassword) != nil {
http.Redirect(w, r, "/settings/user?error=Current+password+is+incorrect", http.StatusSeeOther)
return
}
if len(newPassword) < 8 {
http.Redirect(w, r, "/settings/user?error=New+password+must+be+at+least+8+characters", http.StatusSeeOther)
return
}
if newPassword != confirmPassword {
http.Redirect(w, r, "/settings/user?error=Passwords+do+not+match", http.StatusSeeOther)
return
}
hash, err := h.auth.HashPassword(newPassword)
if err != nil {
http.Redirect(w, r, "/settings/user?error=Failed+to+update+password", http.StatusSeeOther)
return
}
if err := h.repo.UpdateUserPassword(r.Context(), user.ID, hash); err != nil {
http.Redirect(w, r, "/settings/user?error=Failed+to+save+password", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/settings/user?message=Password+updated", http.StatusSeeOther)
}
func (h *Handler) UpdateUserOTP(w http.ResponseWriter, r *http.Request) {
user := localmiddleware.CurrentUser(r)
if user == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
action := chi.URLParam(r, "action")
switch action {
case "disable":
if err := h.repo.UpdateUserOTP(r.Context(), user.ID, user.OTPSecret, false); err != nil {
http.Redirect(w, r, "/settings/user?error=Failed+to+disable+2FA", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/settings/user?message=2FA+disabled", http.StatusSeeOther)
return
case "reset":
key, err := h.auth.NewOTP(user.Email)
if err != nil {
http.Redirect(w, r, "/settings/user?error=Failed+to+reset+2FA", http.StatusSeeOther)
return
}
if err := h.repo.UpdateUserOTP(r.Context(), user.ID, key.Secret(), false); err != nil {
http.Redirect(w, r, "/settings/user?error=Failed+to+reset+2FA", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/settings/user?message=Scan+the+new+QR+code+to+enable+2FA", http.StatusSeeOther)
return
case "enable":
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther)
return
}
if !h.auth.VerifyOTP(user.OTPSecret, strings.TrimSpace(r.FormValue("code"))) {
http.Redirect(w, r, "/settings/user?error=Invalid+OTP+code", http.StatusSeeOther)
return
}
if err := h.repo.UpdateUserOTP(r.Context(), user.ID, user.OTPSecret, true); err != nil {
http.Redirect(w, r, "/settings/user?error=Failed+to+enable+2FA", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/settings/user?message=2FA+enabled", http.StatusSeeOther)
return
default:
http.NotFound(w, r)
}
}
func (h *Handler) render(w http.ResponseWriter, r *http.Request, page, title string, content any, user ...*models.User) {
var currentUser *models.User
if len(user) > 0 {
@@ -812,7 +993,7 @@ func (h *Handler) render(w http.ResponseWriter, r *http.Request, page, title str
org := h.currentOrganization(r.Context())
groups, _ := h.repo.ListGroups(r.Context(), org.ID)
tags, _ := h.repo.ListTags(r.Context(), org.ID)
mode := normalizeMode(org.ThemeMode)
mode := effectiveThemeMode(currentUser, org)
theme := normalizeTheme(org.Theme, mode)
h.renderer.Render(w, page, views.ViewData{
@@ -912,6 +1093,7 @@ func (h *Handler) uptimePageData(ctx context.Context) uptimePageData {
return uptimePageData{
Summary: summary,
Chart: buildUptimeChart(checksByMonitor),
Periods: periods,
Monitors: cards,
Incidents: incidents,
@@ -1041,6 +1223,92 @@ func reverseChecks(checks []models.UptimeCheck) []models.UptimeCheck {
return reversed
}
func buildUptimeChart(checksByMonitor map[int64][]models.UptimeCheck) uptimeChartData {
type bucket struct {
up int
down int
total int
}
buckets := map[time.Time]*bucket{}
for _, checks := range checksByMonitor {
for _, check := range checks {
minute := check.CheckedAt.Truncate(time.Minute)
if buckets[minute] == nil {
buckets[minute] = &bucket{}
}
buckets[minute].total++
if check.Status == "up" {
buckets[minute].up++
} else {
buckets[minute].down++
}
}
}
if len(buckets) == 0 {
return uptimeChartData{}
}
timestamps := make([]time.Time, 0, len(buckets))
for ts := range buckets {
timestamps = append(timestamps, ts)
}
sort.Slice(timestamps, func(i, j int) bool {
return timestamps[i].Before(timestamps[j])
})
if len(timestamps) > 30 {
timestamps = timestamps[len(timestamps)-30:]
}
points := make([]uptimeChartPoint, 0, len(timestamps))
for _, ts := range timestamps {
current := buckets[ts]
availability := 0.0
if current.total > 0 {
availability = float64(current.up) * 100 / float64(current.total)
}
height := int(availability)
if current.total > 0 && height < 8 {
height = 8
}
points = append(points, uptimeChartPoint{
Label: ts.Format("15:04"),
Availability: availability,
AvailabilityText: fmt.Sprintf("%.0f%%", availability),
UpCount: current.up,
DownCount: current.down,
TotalCount: current.total,
AvailabilityHeight: height,
})
}
labels := make([]string, 0, len(points))
values := make([]float64, 0, len(points))
pointColors := make([]string, 0, len(points))
for i := range points {
points[i].ShowLabel = i == 0 || i == len(points)-1 || i == len(points)/2 || i%6 == 0
labels = append(labels, points[i].Label)
values = append(values, points[i].Availability)
if points[i].DownCount > 0 {
pointColors = append(pointColors, "#ef4444")
} else {
pointColors = append(pointColors, "rgb(var(--color-primary-500))")
}
}
labelsJSON, _ := json.Marshal(labels)
valuesJSON, _ := json.Marshal(values)
pointColorsJSON, _ := json.Marshal(pointColors)
return uptimeChartData{
Points: points,
LabelsJSON: template.JS(labelsJSON),
ValuesJSON: template.JS(valuesJSON),
PointColorsJSON: template.JS(pointColorsJSON),
}
}
func shellForPage(page string) string {
switch page {
case "login", "login_otp", "register":
@@ -1079,12 +1347,56 @@ func (h *Handler) nodesRunQuery(ctx context.Context, nodeID int64) (*sql.Rows, e
`, nodeID)
}
func (h *Handler) settingsRuns(ctx context.Context) []models.CommandRun {
func (h *Handler) settingsLogs(ctx context.Context) []settingsCommandLog {
runs, err := h.nodes.ListCommandHistory(ctx, h.org.ID)
if err != nil {
return nil
}
return runs
if len(runs) == 0 {
return nil
}
var logs []settingsCommandLog
for _, run := range runs {
target := run.NodeName
if strings.TrimSpace(run.GroupName) != "" {
target = run.GroupName + " / " + target
}
commandText := strings.TrimSpace(run.CommandText)
if commandText == "" {
commandText = strings.TrimSpace(run.Action)
}
if len(logs) > 0 {
last := &logs[len(logs)-1]
if last.Target == target && last.Status == run.Status && last.StartedAt.Equal(run.StartedAt) {
if commandText != "" && !strings.Contains(last.CommandText, commandText) {
if last.CommandText != "" {
last.CommandText += "\n"
}
last.CommandText += commandText
}
output := strings.TrimSpace(run.Output)
if output != "" && !strings.Contains(last.Output, output) {
if last.Output != "" {
last.Output += "\n\n"
}
last.Output += output
}
continue
}
}
logs = append(logs, settingsCommandLog{
Target: target,
StartedAt: run.StartedAt,
Status: run.Status,
CommandText: commandText,
Output: strings.TrimSpace(run.Output),
})
}
return logs
}
func (h *Handler) nodesDB() *sql.DB {
@@ -1099,21 +1411,6 @@ func encodeBase64(raw []byte) string {
return base64.StdEncoding.EncodeToString(raw)
}
const themePreview = `
:root {
--color-primary-50: 236 253 245;
--color-primary-100: 209 250 229;
--color-primary-200: 167 243 208;
--color-primary-300: 110 231 183;
--color-primary-400: 52 211 153;
--color-primary-500: 16 185 129;
--color-primary-600: 5 150 105;
--color-primary-700: 4 120 87;
--color-primary-800: 6 95 70;
--color-primary-900: 6 78 59;
--color-primary-950: 2 44 34;
}`
func buildSchedule(triggerType, kind, hour, minute, weekday, intervalValue, intervalUnit string) string {
if triggerType == "triggered" {
return ""
@@ -1174,6 +1471,13 @@ func normalizeMode(mode string) string {
return "dark"
}
func effectiveThemeMode(user *models.User, org models.Organization) string {
if user != nil && strings.TrimSpace(user.ThemeMode) != "" {
return normalizeMode(user.ThemeMode)
}
return normalizeMode(org.ThemeMode)
}
func saveKeyUpload(r *http.Request, field string) (string, error) {
file, header, err := r.FormFile(field)
if err != nil {

View File

@@ -27,6 +27,7 @@ type User struct {
Role Role
OTPSecret string
OTPEnabled bool
ThemeMode string
CreatedAt time.Time
}

View File

@@ -188,6 +188,8 @@ func (s *NodeService) EnsureUptimeMonitorForNode(ctx context.Context, node *mode
organization_id = excluded.organization_id,
name = excluded.name,
target = excluded.target,
interval_seconds = 60,
enabled = 1,
updated_at = CURRENT_TIMESTAMP
`, node.OrganizationID, node.ID, name, target)
return err

View File

@@ -71,9 +71,9 @@ func (r *Repository) CountUsers(ctx context.Context) (int, error) {
func (r *Repository) CreateUser(ctx context.Context, user *models.User) error {
result, err := r.db.ExecContext(ctx, `
INSERT INTO users (organization_id, name, email, password_hash, role, otp_secret, otp_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, user.Organization, user.Name, user.Email, user.PasswordHash, user.Role, user.OTPSecret, user.OTPEnabled)
INSERT INTO users (organization_id, name, email, password_hash, role, otp_secret, otp_enabled, theme_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, user.Organization, user.Name, user.Email, user.PasswordHash, user.Role, user.OTPSecret, user.OTPEnabled, user.ThemeMode)
if err != nil {
return err
}
@@ -85,12 +85,12 @@ func (r *Repository) CreateUser(ctx context.Context, user *models.User) error {
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
user := &models.User{}
err := r.db.QueryRowContext(ctx, `
SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, created_at
SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, theme_mode, created_at
FROM users
WHERE email = ?
`, email).Scan(
&user.ID, &user.Organization, &user.Name, &user.Email, &user.PasswordHash, &user.Role,
&user.OTPSecret, &user.OTPEnabled, &user.CreatedAt,
&user.OTPSecret, &user.OTPEnabled, &user.ThemeMode, &user.CreatedAt,
)
if err != nil {
return nil, err
@@ -102,12 +102,12 @@ func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*models.
func (r *Repository) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
user := &models.User{}
err := r.db.QueryRowContext(ctx, `
SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, created_at
SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, theme_mode, created_at
FROM users
WHERE id = ?
`, id).Scan(
&user.ID, &user.Organization, &user.Name, &user.Email, &user.PasswordHash, &user.Role,
&user.OTPSecret, &user.OTPEnabled, &user.CreatedAt,
&user.OTPSecret, &user.OTPEnabled, &user.ThemeMode, &user.CreatedAt,
)
if err != nil {
return nil, err
@@ -121,6 +121,21 @@ func (r *Repository) EnableUserOTP(ctx context.Context, id int64) error {
return err
}
func (r *Repository) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error {
_, err := r.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, id)
return err
}
func (r *Repository) UpdateUserThemeMode(ctx context.Context, id int64, themeMode string) error {
_, err := r.db.ExecContext(ctx, `UPDATE users SET theme_mode = ? WHERE id = ?`, themeMode, id)
return err
}
func (r *Repository) UpdateUserOTP(ctx context.Context, id int64, secret string, enabled bool) error {
_, err := r.db.ExecContext(ctx, `UPDATE users SET otp_secret = ?, otp_enabled = ? WHERE id = ?`, secret, enabled, id)
return err
}
func (r *Repository) EnsurePrimaryAdmin(ctx context.Context) error {
var adminCount int
if err := r.db.QueryRowContext(ctx, `

View File

@@ -26,10 +26,6 @@
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#createGroupModal">
<i class="ti ti-plus me-1"></i>Create Group
</button>
{{else if eq .CurrentPath "/settings"}}
<a class="btn btn-primary" href="#theme-presets">
<i class="ti ti-plus me-1"></i>Themes
</a>
{{else if eq .CurrentPath "/dashboard"}}
<button class="btn btn-primary btn-icon" type="button" data-bs-toggle="modal" data-bs-target="#addVmModal" data-bs-toggle-tooltip="tooltip" data-bs-title="Add VM" aria-label="Add VM">
<i class="ti ti-plus"></i>
@@ -59,7 +55,7 @@
<a href="/groups" class="app-nav-link {{if eq .CurrentPath "/groups"}}is-active{{end}}"><i class="ti ti-stack-2"></i><span>Groups</span></a>
<a href="/automations" class="app-nav-link {{if eq .CurrentPath "/automations"}}is-active{{end}}"><i class="ti ti-clock-cog"></i><span>Jobs</span></a>
<a href="/uptime" class="app-nav-link {{if eq .CurrentPath "/uptime"}}is-active{{end}}"><i class="ti ti-activity-heartbeat"></i><span>Uptime</span></a>
<a href="/settings" class="app-nav-link {{if eq .CurrentPath "/settings"}}is-active{{end}}"><i class="ti ti-settings-2"></i><span>Settings</span></a>
<a href="/settings" class="app-nav-link {{if contains .CurrentPath "/settings"}}is-active{{end}}"><i class="ti ti-settings-2"></i><span>Settings</span></a>
</nav>
<div class="card mt-auto border-0 sidebar-status shadow-sm">
@@ -68,11 +64,14 @@
<div class="d-flex align-items-center gap-3 min-w-0">
<img src="/static/img/maintainarr_logo.png" alt="" class="sidebar-logo">
<div class="min-w-0">
<div class="fw-semibold text-truncate">{{with .User}}{{.Name}}{{end}}</div>
<div class="text-body-secondary small text-truncate">{{with .User}}{{.Role}}{{end}}</div>
<a class="fw-semibold text-truncate text-decoration-none text-reset d-block" href="/settings/user">{{with .User}}{{.Name}}{{end}}</a>
<div class="text-body-secondary small text-truncate"><i class="ti ti-shield-half-filled me-1"></i>{{with .User}}{{.Role}}{{end}}</div>
</div>
</div>
<a class="btn btn-outline-secondary btn-sm" href="/logout"><i class="fa-solid fa-right-from-bracket"></i></a>
<div class="d-flex align-items-center gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/settings/user" data-bs-toggle-tooltip="tooltip" data-bs-title="User Settings" aria-label="User Settings"><i class="ti ti-user-cog"></i></a>
<a class="btn btn-outline-secondary btn-sm" href="/logout" data-bs-toggle-tooltip="tooltip" data-bs-title="Logout" aria-label="Logout"><i class="fa-solid fa-right-from-bracket"></i></a>
</div>
</div>
</div>
</div>
@@ -85,7 +84,15 @@
<div class="container-fluid px-3 px-lg-4">
<div class="d-flex align-items-center justify-content-between gap-3 py-3">
<div>
<div class="fw-semibold text-body-emphasis">{{.Title}}</div>
<div class="fw-semibold text-body-emphasis d-flex align-items-center gap-2">
{{if eq .CurrentPath "/dashboard"}}<i class="ti ti-layout-grid"></i>{{end}}
{{if eq .CurrentPath "/groups"}}<i class="ti ti-stack-2"></i>{{end}}
{{if eq .CurrentPath "/automations"}}<i class="ti ti-clock-cog"></i>{{end}}
{{if eq .CurrentPath "/uptime"}}<i class="ti ti-activity-heartbeat"></i>{{end}}
{{if contains .CurrentPath "/settings"}}<i class="ti ti-settings-2"></i>{{end}}
{{if contains .CurrentPath "/nodes/"}}<i class="ti ti-server-2"></i>{{end}}
<span>{{.Title}}</span>
</div>
</div>
<div class="d-flex align-items-center gap-2">
{{template "contextAction" .}}
@@ -104,8 +111,8 @@
{{if not (contains .CurrentPath "/console")}}
<footer class="app-footer border-top">
<div class="container-fluid px-3 px-lg-4 py-3 d-flex flex-column flex-md-row justify-content-between gap-2 small text-body-secondary">
<span>{{with .Organization}}{{.Name}}{{end}}</span>
<span>{{with .User}}{{.Name}} · {{.Role}}{{end}}</span>
<span><i class="ti ti-building-community me-1"></i>{{with .Organization}}{{.Name}}{{end}}</span>
<span><i class="ti ti-user-circle me-1"></i>{{with .User}}{{.Name}} · {{.Role}}{{end}}</span>
</div>
</footer>
{{end}}
@@ -118,7 +125,7 @@
<div class="modal-content border-0 shadow-lg">
<form method="post" action="/groups">
<div class="modal-header">
<h2 class="modal-title fs-5">Create Group</h2>
<h2 class="modal-title fs-5"><i class="ti ti-stack-2 me-2"></i>Create Group</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -132,7 +139,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal"><i class="ti ti-x me-1"></i>Cancel</button>
<button type="submit" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Create Group</button>
</div>
</form>
@@ -145,13 +152,13 @@
<div class="modal-content border-0 shadow-lg">
<form method="post" action="/nodes" enctype="multipart/form-data">
<div class="modal-header">
<h2 class="modal-title fs-5">Add VM</h2>
<h2 class="modal-title fs-5"><i class="ti ti-server-2 me-2"></i>Add VM</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4 p-lg-5">
<div class="add-vm-form">
<section class="add-vm-panel">
<div class="add-vm-panel-title">Node</div>
<div class="add-vm-panel-title"><i class="ti ti-server-2 me-2"></i>Node</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<label class="form-label">IP address</label>
@@ -179,7 +186,7 @@
</section>
<section class="add-vm-panel">
<div class="add-vm-panel-title">Access</div>
<div class="add-vm-panel-title"><i class="ti ti-key me-2"></i>Access</div>
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" name="ssh_username" placeholder="root" required>
@@ -188,12 +195,12 @@
<div class="auth-toggle mb-3" data-auth-toggle>
<input type="radio" class="btn-check" name="auth_mode" id="authModePassword" value="password" autocomplete="off" checked>
<label class="auth-toggle-option" for="authModePassword">
<span class="auth-toggle-label">Password</span>
<span class="auth-toggle-label"><i class="ti ti-lock me-2"></i>Password</span>
</label>
<input type="radio" class="btn-check" name="auth_mode" id="authModeKey" value="key" autocomplete="off">
<label class="auth-toggle-option" for="authModeKey">
<span class="auth-toggle-label">Key File</span>
<span class="auth-toggle-label"><i class="ti ti-key me-2"></i>Key File</span>
</label>
</div>
@@ -209,7 +216,7 @@
</section>
<section class="add-vm-panel add-vm-panel-wide">
<div class="add-vm-panel-title">Options</div>
<div class="add-vm-panel-title"><i class="ti ti-adjustments me-2"></i>Options</div>
<div class="row g-3 align-items-start">
<div class="col-12 col-lg-8">
<label class="form-label">Notes</label>
@@ -227,7 +234,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal"><i class="ti ti-x me-1"></i>Cancel</button>
<button type="submit" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Create VM</button>
</div>
</form>

View File

@@ -5,6 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Maintainarr</title>
<link rel="icon" type="image/png" sizes="256x256" href="/static/img/favicon-rounded.png">
<link rel="apple-touch-icon" href="/static/img/favicon-rounded.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@@ -18,6 +20,7 @@
<body class="theme-{{.ThemeClass}} bg-body-tertiary" data-bs-theme="{{.ThemeMode}}">
{{template "shell" .}}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.5.0/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="/static/js/app.js"></script>

View File

@@ -2,94 +2,102 @@
{{$data := .Content}}
<section class="mb-4">
<div>
<div class="text-uppercase small fw-semibold text-primary mb-2">Control Plane</div>
<h1 class="display-6 fw-bold mb-2">Jobs</h1>
<div class="text-uppercase small fw-semibold text-primary mb-2"><i class="ti ti-cpu-2 me-1"></i>Control Plane</div>
<h1 class="display-6 fw-bold mb-2"><i class="ti ti-clock-cog me-2"></i>Jobs</h1>
<p class="text-body-secondary mb-0">Schedules, targets, runs, history, output.</p>
</div>
</section>
<section class="row g-4 mb-4">
<div class="col-12">
<article class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="h4 mb-1">Scheduled Jobs</h2>
<p class="text-body-secondary mb-0">Node, group, or tag targets with next run, last run, and command order.</p>
</div>
<span class="badge text-bg-primary">{{len $data.Jobs}} jobs</span>
</div>
<div class="row g-3">
{{range $data.Jobs}}
<div class="col-12 col-xxl-6">
<div class="border rounded-4 p-3 bg-body-tertiary h-100">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<article class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<strong>{{.Name}}</strong>
<div class="small text-body-secondary">{{.TriggerType}} · {{if .NodeName}}{{.NodeName}}{{else if .GroupName}}{{.GroupName}}{{else if .Tag}}tag:{{.Tag}}{{else}}unassigned{{end}}</div>
<div class="small text-body-secondary">{{if .Schedule}}{{.Schedule}}{{else}}manual{{end}}</div>
<h2 class="h4 mb-1"><i class="ti ti-calendar-time me-2"></i>Scheduled Jobs</h2>
<p class="text-body-secondary mb-0">Node, group, or tag targets with next run, last run, and command order.</p>
</div>
<span class="badge {{if .Enabled}}text-bg-success{{else}}text-bg-secondary{{end}}">{{if .Enabled}}Enabled{{else}}Paused{{end}}</span>
<span class="badge text-bg-primary">{{len $data.Jobs}} jobs</span>
</div>
<div class="row g-3 small mb-3">
<div class="col-sm-4">
<div class="text-body-secondary">Last run</div>
<div>{{if .LastRunAt}}{{.LastRunAt.Format "2006-01-02 15:04"}}{{else}}Never{{end}}</div>
<div class="row g-3">
{{range $data.Jobs}}
<div class="col-12 col-xxl-6">
<div class="border rounded-4 p-3 bg-body-tertiary h-100">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<strong><i class="ti ti-bolt me-2"></i>{{.Name}}</strong>
<div class="small text-body-secondary">{{.TriggerType}} · {{if .NodeName}}{{.NodeName}}{{else if .GroupName}}{{.GroupName}}{{else if .Tag}}tag:{{.Tag}}{{else}}unassigned{{end}}</div>
<div class="small text-body-secondary">{{if .Schedule}}{{.Schedule}}{{else}}manual{{end}}</div>
</div>
<span class="badge {{if .Enabled}}text-bg-success{{else}}text-bg-secondary{{end}}">{{if .Enabled}}Enabled{{else}}Paused{{end}}</span>
</div>
<div class="row g-3 small mb-3">
<div class="col-sm-4">
<div class="text-body-secondary">Last run</div>
<div>{{if .LastRunAt}}{{.LastRunAt.Format "2006-01-02 15:04"}}{{else}}Never{{end}}</div>
</div>
<div class="col-sm-4">
<div class="text-body-secondary">Next run</div>
<div>{{if .NextRunAt}}{{.NextRunAt.Format "2006-01-02 15:04"}}{{else}}Manual{{end}}</div>
</div>
<div class="col-sm-4">
<div class="text-body-secondary">Target</div>
<div>{{if .NodeName}}Node{{else if .GroupName}}Group{{else if .Tag}}Tag{{else}}None{{end}}</div>
</div>
</div>
<ol class="mb-0 ps-3 small">
{{range (splitLines .Command)}}
<li class="mb-1"><code>{{.}}</code></li>
{{end}}
</ol>
</div>
</div>
<div class="col-sm-4">
<div class="text-body-secondary">Next run</div>
<div>{{if .NextRunAt}}{{.NextRunAt.Format "2006-01-02 15:04"}}{{else}}Manual{{end}}</div>
{{else}}
<div class="col-12">
<div class="text-center text-body-secondary py-5">
<i class="ti ti-clock-off fs-1 d-block mb-3"></i>No jobs yet.
</div>
</div>
<div class="col-sm-4">
<div class="text-body-secondary">Target</div>
<div>{{if .NodeName}}Node{{else if .GroupName}}Group{{else if .Tag}}Tag{{else}}None{{end}}</div>
</div>
</div>
<ol class="mb-0 ps-3 small">
{{range (splitLines .Command)}}
<li class="mb-1"><code>{{.}}</code></li>
{{end}}
</ol>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
</article>
</article>
</div>
</section>
<section class="row g-4">
<div class="col-12">
<article class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="h4 mb-1">Job History</h2>
<p class="text-body-secondary mb-0">Run duration, output, target node, and status.</p>
</div>
</div>
<div class="vstack gap-3">
{{range $data.Runs}}
<div class="border rounded-4 p-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
<article class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<strong>{{if .JobName}}{{.JobName}}{{else}}{{.Action}}{{end}}</strong>
<div class="small text-body-secondary">{{.NodeName}} · {{.StartedAt.Format "2006-01-02 15:04"}}</div>
</div>
<div class="text-end">
<div><span class="badge {{if eq .Status "completed"}}text-bg-success{{else}}text-bg-danger{{end}}">{{.Status}}</span></div>
<div class="small text-body-secondary mt-1">{{.DurationText}}</div>
<h2 class="h4 mb-1"><i class="ti ti-history me-2"></i>Job History</h2>
<p class="text-body-secondary mb-0">Run duration, output, target node, and status.</p>
</div>
</div>
<pre class="run-pre mb-0">{{.Output}}</pre>
<div class="vstack gap-3">
{{range $data.Runs}}
<div class="border rounded-4 p-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
<div>
<strong><i class="ti ti-terminal-2 me-2"></i>{{if .JobName}}{{.JobName}}{{else}}{{.Action}}{{end}}</strong>
<div class="small text-body-secondary">{{.NodeName}} · {{.StartedAt.Format "2006-01-02 15:04"}}</div>
</div>
<div class="text-end">
<div><span class="badge {{if eq .Status "completed"}}text-bg-success{{else}}text-bg-danger{{end}}">{{.Status}}</span></div>
<div class="small text-body-secondary mt-1">{{.DurationText}}</div>
</div>
</div>
<pre class="run-pre mb-0">{{.Output}}</pre>
</div>
{{else}}
<div class="text-center text-body-secondary py-5">
<i class="ti ti-history-off fs-1 d-block mb-3"></i>No job runs yet.
</div>
{{end}}
</div>
</div>
{{else}}
<div class="text-body-secondary">No job runs yet.</div>
{{end}}
</div>
</div>
</article>
</div>
</article>
</div>
</section>
<div class="modal fade" id="addJobModal" tabindex="-1" aria-hidden="true">
@@ -97,7 +105,7 @@
<div class="modal-content border-0 shadow-lg">
<form method="post" action="/automations" id="job-form">
<div class="modal-header">
<h2 class="modal-title fs-5">Create Job</h2>
<h2 class="modal-title fs-5"><i class="ti ti-clock-cog me-2"></i>Create Job</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -189,7 +197,7 @@
<div class="col-12 col-lg-4">
<div class="d-flex align-items-center justify-content-between mb-2">
<label class="form-label mb-0">Commands</label>
<button class="btn btn-sm btn-outline-secondary" type="button" id="add-command-step">Add step</button>
<button class="btn btn-sm btn-outline-secondary" type="button" id="add-command-step"><i class="ti ti-plus me-1"></i>Add step</button>
</div>
<div id="command-steps" class="vstack gap-2">
<div class="input-group command-step">
@@ -201,7 +209,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal"><i class="ti ti-x me-1"></i>Cancel</button>
<button type="submit" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Create Job</button>
</div>
</form>

View File

@@ -1,6 +1,8 @@
{{define "content"}}
{{$data := .Content}}
<section class="console-panel">
<div id="console" class="console-output console-terminal" data-ws="/nodes/{{$data.Node.ID}}/console/ws" data-xterm="true"></div>
<section class="console-page">
<div class="console-page-terminal">
<div id="console" class="console-output console-terminal console-terminal-fullscreen" data-ws="/nodes/{{$data.Node.ID}}/console/ws" data-xterm="true"></div>
</div>
</section>
{{end}}

View File

@@ -4,23 +4,23 @@
<section>
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead><tr><th>Name</th><th>Group</th><th>Distro</th><th>IP</th><th>Uptime</th></tr></thead>
<tbody>
{{range $data.Nodes}}
<tr><td>{{.Name}}</td><td>{{if .GroupName}}{{.GroupName}}{{end}}</td><td>{{.Distro}}</td><td>{{.IPAddress}}</td><td>{{uptime .UptimeSeconds}}</td></tr>
{{end}}
</tbody>
</table>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead><tr><th><i class="ti ti-server-2 me-2"></i>Name</th><th><i class="ti ti-stack-2 me-2"></i>Group</th><th><i class="ti ti-brand-linux me-2"></i>Distro</th><th><i class="ti ti-world me-2"></i>IP</th><th><i class="ti ti-clock-hour-4 me-2"></i>Uptime</th></tr></thead>
<tbody>
{{range $data.Nodes}}
<tr><td><i class="{{nodeIconClass .Distro .PackageManager}} me-2"></i>{{.Name}}</td><td>{{if .GroupName}}{{.GroupName}}{{end}}</td><td>{{.Distro}}</td><td>{{.IPAddress}}</td><td>{{uptime .UptimeSeconds}}</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</section>
{{else}}
<section class="card border-0 shadow-sm">
<div class="card-body py-5 text-center text-body-secondary">
No nodes.
<i class="ti ti-server-off fs-1 d-block mb-3"></i>No nodes.
</div>
</section>
{{end}}

View File

@@ -1,20 +1,20 @@
{{define "content"}}
<div class="text-center mb-4">
<h1 class="h3 fw-bold mb-0">Sign in</h1>
<h1 class="h3 fw-bold mb-0"><i class="ti ti-login me-2"></i>Sign in</h1>
</div>
{{with .Content}}{{with .Error}}<div class="alert alert-danger">{{.}}</div>{{end}}{{end}}
<form method="post">
<div class="mb-3">
<label class="form-label">Email</label>
<label class="form-label"><i class="ti ti-mail me-2"></i>Email</label>
<input type="email" class="form-control auth-input" name="email" required>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<label class="form-label"><i class="ti ti-lock me-2"></i>Password</label>
<input type="password" class="form-control auth-input" name="password" required>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">Sign in</button>
<button type="submit" class="btn btn-primary btn-lg w-100"><i class="ti ti-arrow-right me-2"></i>Sign in</button>
</form>
<div class="text-center mt-4 small text-body-secondary">
<a href="/register" class="link-primary link-offset-2">Create account</a>
<a href="/register" class="link-primary link-offset-2"><i class="ti ti-user-plus me-1"></i>Create account</a>
</div>
{{end}}

View File

@@ -1,11 +1,11 @@
{{define "content"}}
<div class="mb-4">
<h2 class="fw-bold mb-0">Verify OTP</h2>
<h2 class="fw-bold mb-0"><i class="ti ti-shield-check me-2"></i>Verify OTP</h2>
</div>
{{with .Content}}{{with .Error}}<div class="alert alert-danger">{{.}}</div>{{end}}{{end}}
<form method="post">
<div class="mb-4">
<label class="form-label">6-digit code</label>
<label class="form-label"><i class="ti ti-keyboard me-2"></i>6-digit code</label>
<input type="text" class="form-control form-control-lg" name="code" inputmode="numeric" required>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100"><i class="ti ti-lock-check me-2"></i>Unlock</button>

View File

@@ -2,11 +2,18 @@
{{$data := .Content}}
<section class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-end gap-3 mb-4">
<div>
<div class="text-uppercase small fw-semibold text-primary mb-2">{{$data.Node.Distro}}</div>
<h1 class="display-6 fw-bold mb-2">{{$data.Node.Name}}</h1>
<p class="text-body-secondary mb-0">{{$data.Node.Hostname}} · {{$data.Node.IPAddress}}{{if $data.Node.GroupName}} · {{$data.Node.GroupName}}{{end}}{{if $data.Node.Tag}} · #{{$data.Node.Tag}}{{end}}</p>
<h1 class="display-6 fw-bold mb-0">{{$data.Node.Name}}</h1>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-primary" href="/nodes/{{$data.Node.ID}}/console">
<i class="ti ti-terminal-2 me-2"></i>Open Console
</a>
<button class="btn btn-outline-secondary" type="button" data-copy-value="{{$data.Node.IPAddress}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy IP">
<i class="ti ti-copy me-2"></i>Copy IP
</button>
<button class="btn btn-outline-secondary" type="button" data-copy-value="ssh -p {{$data.Node.SSHPort}} {{$data.Node.SSHUsername}}@{{$data.Node.IPAddress}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy SSH command">
<i class="ti ti-terminal-2 me-2"></i>Copy SSH
</button>
<form method="post" action="/nodes/{{$data.Node.ID}}/actions/refresh"><button class="btn btn-outline-primary"><i class="ti ti-refresh me-2"></i>Refresh Stats</button></form>
<form method="post" action="/nodes/{{$data.Node.ID}}/actions/wake"><button class="btn btn-outline-secondary"><i class="ti ti-bolt me-2"></i>Wake</button></form>
<form method="post" action="/nodes/{{$data.Node.ID}}/actions/restart"><button class="btn btn-outline-secondary"><i class="ti ti-rotate-clockwise-2 me-2"></i>Restart</button></form>
@@ -20,18 +27,8 @@
<div class="card border-0 shadow-sm stat-card kpi-card kpi-card-featured h-100" data-kpi="cpu">
<div class="kpi-graph"></div>
<div class="card-body position-relative">
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
<div>
<div class="kpi-card-label">CPU usage</div>
<div class="kpi-card-subtle">Live</div>
</div>
<span class="kpi-card-chip">Last minute</span>
</div>
<div class="kpi-card-label mb-3">CPU usage</div>
<div class="kpi-card-featured-value" data-kpi-value="cpu">{{printf "%.1f" $data.Node.CPUUsage}}%</div>
<div class="kpi-card-featured-trend">
<span class="kpi-card-trend is-flat" data-kpi-trend="cpu">0%</span>
<span class="kpi-card-trend-copy">from previous sample</span>
</div>
</div>
</div>
</div>
@@ -88,22 +85,34 @@
</div>
</div>
<div class="system-summary-stats">
<div class="system-summary-stat">
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.IPAddress}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy IP">
<span class="text-body-secondary">IP</span>
<span class="text-end">{{$data.Node.IPAddress}}</span>
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.MACAddress}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy MAC">
<span class="text-body-secondary">MAC</span>
<span class="text-end">{{if $data.Node.MACAddress}}{{$data.Node.MACAddress}}{{else}}-{{end}}</span>
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.SSHUsername}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy username">
<span class="text-body-secondary">Username</span>
<span class="text-end">{{if $data.Node.SSHUsername}}{{$data.Node.SSHUsername}}{{else}}-{{end}}</span>
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.Architecture}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy architecture">
<span class="text-body-secondary">Architecture</span>
<span class="text-end">{{$data.Node.Architecture}}</span>
</div>
<div class="system-summary-stat">
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.KernelVersion}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy kernel">
<span class="text-body-secondary">Kernel</span>
<span class="text-end">{{$data.Node.KernelVersion}}</span>
</div>
<div class="system-summary-stat">
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{if $data.Node.MemoryTotalMB}}{{$data.Node.MemoryTotalMB}} MB{{end}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy memory">
<span class="text-body-secondary">Memory</span>
<span class="text-end">{{if $data.Node.MemoryTotalMB}}{{$data.Node.MemoryTotalMB}} MB{{else}}-{{end}}</span>
</div>
<div class="system-summary-stat">
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{if $data.Node.DiskTotalGB}}{{$data.Node.DiskTotalGB}} GB{{end}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy disk">
<span class="text-body-secondary">Disk</span>
<span class="text-end">{{if $data.Node.DiskTotalGB}}{{$data.Node.DiskTotalGB}} GB{{else}}-{{end}}</span>
</div>
</button>
</div>
</div>
</div>

View File

@@ -1,24 +1,24 @@
{{define "content"}}
<div class="text-center mb-4">
<h1 class="h3 fw-bold mb-0">Create account</h1>
<h1 class="h3 fw-bold mb-0"><i class="ti ti-user-plus me-2"></i>Create account</h1>
</div>
{{with .Content}}{{with .Error}}<div class="alert alert-danger">{{.}}</div>{{end}}{{end}}
<form method="post">
<div class="mb-3">
<label class="form-label">Name</label>
<label class="form-label"><i class="ti ti-user me-2"></i>Name</label>
<input type="text" class="form-control auth-input" name="name" required>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<label class="form-label"><i class="ti ti-mail me-2"></i>Email</label>
<input type="email" class="form-control auth-input" name="email" required>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<label class="form-label"><i class="ti ti-lock me-2"></i>Password</label>
<input type="password" class="form-control auth-input" name="password" required>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">Create account</button>
<button type="submit" class="btn btn-primary btn-lg w-100"><i class="ti ti-check me-2"></i>Create account</button>
</form>
<div class="text-center mt-4 small text-body-secondary">
<a href="/login" class="link-primary link-offset-2">Back to sign in</a>
<a href="/login" class="link-primary link-offset-2"><i class="ti ti-arrow-left me-1"></i>Back to sign in</a>
</div>
{{end}}

View File

@@ -2,84 +2,119 @@
{{$data := .Content}}
<section class="mb-4">
<div>
<div class="text-uppercase small fw-semibold text-primary mb-2">Themes</div>
<h1 class="display-6 fw-bold mb-2">Appearance</h1>
<p class="text-body-secondary mb-0">Dark or light only.</p>
<div class="text-uppercase small fw-semibold text-primary mb-2"><i class="ti ti-settings-2 me-1"></i>System</div>
<h1 class="display-6 fw-bold mb-2"><i class="ti ti-adjustments me-2"></i>Settings</h1>
<p class="text-body-secondary mb-0">Application-wide status and command logs.</p>
</div>
</section>
<section class="row g-4">
<div class="col-12 col-xl-8">
<article class="card border-0 shadow-sm h-100" id="theme-presets">
<div class="card-body p-4">
<h2 class="h4 mb-3">Theme Presets</h2>
<form method="post" action="/settings/theme">
<input type="hidden" name="mode" value="{{$data.CurrentTheme}}">
<div class="row g-3 mb-4">
<div class="col-12 col-md-6">
<input type="radio" class="btn-check" name="theme" id="theme-dark" value="dark" {{if eq $data.CurrentTheme "dark"}}checked{{end}}>
<label class="theme-card d-block h-100" for="theme-dark">
<span class="theme-swatch swatch-dark"></span>
<strong>Dark</strong>
<small>Neutral dark base</small>
</label>
</div>
<div class="col-12 col-md-6">
<input type="radio" class="btn-check" name="theme" id="theme-light" value="light" {{if eq $data.CurrentTheme "light"}}checked{{end}}>
<label class="theme-card d-block h-100" for="theme-light">
<span class="theme-swatch swatch-light"></span>
<strong>Light</strong>
<small>Neutral light base</small>
</label>
</div>
</div>
<button type="submit" class="btn btn-primary">Save Theme</button>
</form>
</div>
</article>
</div>
<div class="col-12 col-xl-4">
<section class="row g-3 mb-4">
<div class="col-12 col-sm-6 col-xxl-3">
<article class="card border-0 shadow-sm h-100">
<div class="card-body p-4">
<h2 class="h4 mb-3">Current</h2>
<div class="theme-preview d-grid gap-3">
<div class="preview-card"></div>
<div class="preview-card accent"></div>
<div class="preview-card dim"></div>
<div class="small text-uppercase text-body-secondary fw-semibold mb-2"><i class="ti ti-server-2 me-2"></i>Nodes</div>
<div class="display-6 fw-bold">{{$data.NodeCount}}</div>
</div>
</article>
</div>
<div class="col-12 col-sm-6 col-xxl-3">
<article class="card border-0 shadow-sm h-100">
<div class="card-body p-4">
<div class="small text-uppercase text-body-secondary fw-semibold mb-2"><i class="ti ti-stack-2 me-2"></i>Groups</div>
<div class="display-6 fw-bold">{{$data.GroupCount}}</div>
</div>
</article>
</div>
<div class="col-12 col-sm-6 col-xxl-3">
<article class="card border-0 shadow-sm h-100">
<div class="card-body p-4">
<div class="small text-uppercase text-body-secondary fw-semibold mb-2"><i class="ti ti-clock-cog me-2"></i>Jobs</div>
<div class="display-6 fw-bold">{{$data.JobCount}}</div>
</div>
</article>
</div>
<div class="col-12 col-sm-6 col-xxl-3">
<article class="card border-0 shadow-sm h-100">
<div class="card-body p-4">
<div class="small text-uppercase text-body-secondary fw-semibold mb-2"><i class="ti ti-users me-2"></i>Users</div>
<div class="display-6 fw-bold">{{$data.UserCount}}</div>
</div>
</article>
</div>
</section>
<section class="row g-4 mb-4">
<div class="col-12 col-xl-6">
<article class="card border-0 shadow-sm h-100">
<div class="card-body p-4">
<h2 class="h4 mb-3"><i class="ti ti-building-community me-2"></i>Organization</h2>
<div class="vstack gap-3">
<div class="d-flex justify-content-between gap-3">
<span class="text-body-secondary">Name</span>
<strong>{{with $.Organization}}{{.Name}}{{end}}</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-body-secondary">Topology</span>
<strong>Single organization</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-body-secondary">Database</span>
<strong>SQLite</strong>
</div>
</div>
<div class="mt-4 small text-body-secondary">
Theme: <strong class="text-body-emphasis">{{$data.CurrentTheme}}</strong><br>
Mode: <strong class="text-body-emphasis">{{$data.CurrentMode}}</strong>
</div>
</article>
</div>
<div class="col-12 col-xl-6">
<article class="card border-0 shadow-sm h-100">
<div class="card-body p-4">
<h2 class="h4 mb-3"><i class="ti ti-shield-lock me-2"></i>Access</h2>
<div class="vstack gap-3">
<div class="d-flex justify-content-between gap-3">
<span class="text-body-secondary">Roles</span>
<strong>Admin / Editor / Viewer</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-body-secondary">2FA</span>
<strong>OTP</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-body-secondary">First user</span>
<strong>Always Admin</strong>
</div>
</div>
</div>
</article>
</div>
</section>
<section class="mt-4">
<section>
<article class="card border-0 shadow-sm">
<div class="card-body p-4">
<h2 class="h4 mb-3">Command History</h2>
<h2 class="h4 mb-3"><i class="ti ti-history me-2"></i>Command History</h2>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>Group/VMid</th>
<th>Time</th>
<th>Status</th>
<th>Command</th>
<th><i class="ti ti-stack-2 me-2"></i>Group/VMid</th>
<th><i class="ti ti-clock me-2"></i>Time</th>
<th><i class="ti ti-circle-check me-2"></i>Status</th>
<th><i class="ti ti-terminal-2 me-2"></i>Command</th>
</tr>
</thead>
<tbody>
{{if $data.Runs}}
{{range $data.Runs}}
{{if $data.Logs}}
{{range $data.Logs}}
<tr>
<td class="text-nowrap">{{if .GroupName}}{{.GroupName}} / {{end}}{{.NodeName}}</td>
<td class="text-nowrap">{{.Target}}</td>
<td class="text-nowrap">{{.StartedAt.Format "2006-01-02 15:04:05"}}</td>
<td><span class="badge {{if eq .Status "completed"}}text-bg-success{{else}}text-bg-danger{{end}}">{{.Status}}</span></td>
<td><code>{{if .CommandText}}{{.CommandText}}{{else}}{{.Action}}{{end}}</code></td>
<td>
<code class="d-block text-break" style="white-space: pre-wrap;">{{.CommandText}}</code>
{{if .Output}}
<div class="small text-body-secondary mt-2 text-break" style="white-space: pre-wrap;">{{.Output}}</div>
{{end}}
</td>
</tr>
{{end}}
{{else}}

View File

@@ -1,30 +1,30 @@
{{define "content"}}
{{$c := .Content}}
<section class="mb-4">
<span class="text-uppercase small fw-semibold text-primary">Security</span>
<h1 class="h2 fw-bold mt-2 mb-0">Enable OTP 2FA</h1>
<span class="text-uppercase small fw-semibold text-primary"><i class="ti ti-shield-lock me-1"></i>Security</span>
<h1 class="h2 fw-bold mt-2 mb-0"><i class="ti ti-device-mobile me-2"></i>Enable OTP 2FA</h1>
</section>
{{with $c}}{{with .Error}}<div class="alert alert-danger">{{.}}</div>{{end}}{{end}}
<section class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="row g-4 align-items-center">
<div class="col-md-4">
<div class="qr-panel text-center p-3 bg-light rounded-4 border">
<img alt="OTP QR code" src="{{with $c}}{{.QRCode}}{{end}}">
</div>
<div class="qr-panel text-center p-3 bg-light rounded-4 border">
<img alt="OTP QR code" src="{{with $c}}{{.QRCode}}{{end}}">
</div>
</div>
<div class="col-md-8">
<form method="post">
<div class="mb-3">
<label class="form-label">Manual secret</label>
<input type="text" class="form-control" readonly value="{{with $c}}{{.Secret}}{{end}}">
</div>
<div class="mb-4">
<label class="form-label">Authenticator code</label>
<input type="text" class="form-control form-control-lg" name="code" inputmode="numeric" required>
</div>
<button type="submit" class="btn btn-primary"><i class="ti ti-shield-check me-2"></i>Enable 2FA</button>
</form>
<form method="post">
<div class="mb-3">
<label class="form-label"><i class="ti ti-key me-2"></i>Manual secret</label>
<input type="text" class="form-control" readonly value="{{with $c}}{{.Secret}}{{end}}">
</div>
<div class="mb-4">
<label class="form-label"><i class="ti ti-lock-password me-2"></i>Authenticator code</label>
<input type="text" class="form-control form-control-lg" name="code" inputmode="numeric" required>
</div>
<button type="submit" class="btn btn-primary"><i class="ti ti-shield-check me-2"></i>Enable 2FA</button>
</form>
</div>
</div>
</div>

View File

@@ -4,7 +4,7 @@
<div class="col-12 col-md-6 col-xxl-3">
<article class="card border-0 shadow-sm uptime-summary-card h-100">
<div class="card-body">
<div class="uptime-summary-label">Monitors</div>
<div class="uptime-summary-label"><i class="ti ti-radar-2 me-2"></i>Monitors</div>
<div class="uptime-summary-value">{{$data.Summary.TotalMonitors}}</div>
</div>
</article>
@@ -12,7 +12,7 @@
<div class="col-12 col-md-6 col-xxl-3">
<article class="card border-0 shadow-sm uptime-summary-card h-100">
<div class="card-body">
<div class="uptime-summary-label">Up</div>
<div class="uptime-summary-label"><i class="ti ti-circle-check me-2"></i>Up</div>
<div class="uptime-summary-value">{{$data.Summary.UpMonitors}}</div>
</div>
</article>
@@ -20,7 +20,7 @@
<div class="col-12 col-md-6 col-xxl-3">
<article class="card border-0 shadow-sm uptime-summary-card h-100">
<div class="card-body">
<div class="uptime-summary-label">Down</div>
<div class="uptime-summary-label"><i class="ti ti-alert-circle me-2"></i>Down</div>
<div class="uptime-summary-value">{{$data.Summary.DownMonitors}}</div>
</div>
</article>
@@ -28,7 +28,7 @@
<div class="col-12 col-md-6 col-xxl-3">
<article class="card border-0 shadow-sm uptime-summary-card h-100">
<div class="card-body">
<div class="uptime-summary-label">Avg latency</div>
<div class="uptime-summary-label"><i class="ti ti-wave-sine me-2"></i>Avg latency</div>
<div class="uptime-summary-value">{{if $data.Summary.AvgLatencyMS}}{{$data.Summary.AvgLatencyMS}}ms{{else}}-{{end}}</div>
</div>
</article>
@@ -40,64 +40,26 @@
<article class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h2 class="h4 mb-0">Monitors</h2>
<span class="text-body-secondary small">SSH endpoint checks</span>
<div>
<h2 class="h4 mb-1"><i class="ti ti-chart-line me-2"></i>Fleet availability</h2>
<div class="text-body-secondary small">Last 30 one-minute samples</div>
</div>
<span class="text-body-secondary small">1 minute interval</span>
</div>
<div class="row g-3">
{{if $data.Monitors}}
{{range $data.Monitors}}
<div class="col-12 col-xl-6">
<article class="uptime-monitor-card {{if eq .Monitor.LastStatus "down"}}is-down{{else if eq .Monitor.LastStatus "up"}}is-up{{else}}is-pending{{end}}">
<div class="uptime-monitor-head">
<div>
<div class="fw-semibold text-body-emphasis">{{.Monitor.Name}}</div>
<div class="small text-body-secondary">{{.Monitor.Target}}{{if .Monitor.GroupName}} · {{.Monitor.GroupName}}{{end}}</div>
</div>
<span class="uptime-monitor-badge {{if eq .Monitor.LastStatus "down"}}is-down{{else if eq .Monitor.LastStatus "up"}}is-up{{else}}is-pending{{end}}">
{{if eq .Monitor.LastStatus "down"}}Down{{else if eq .Monitor.LastStatus "up"}}Up{{else}}Pending{{end}}
</span>
</div>
<div class="uptime-monitor-stats">
<div class="uptime-monitor-stat">
<span>Availability</span>
<strong>{{.AvailabilityText}}</strong>
</div>
<div class="uptime-monitor-stat">
<span>Latency</span>
<strong>{{if .Monitor.LastLatencyMS}}{{.Monitor.LastLatencyMS}}ms{{else}}-{{end}}</strong>
</div>
<div class="uptime-monitor-stat">
<span>Checked</span>
<strong>{{.LastCheckedText}}</strong>
</div>
<div class="uptime-monitor-stat">
<span>Interval</span>
<strong>{{.IntervalText}}</strong>
</div>
</div>
<div class="uptime-check-strip" aria-hidden="true">
{{if .RecentChecks}}
{{range .RecentChecks}}
<span class="uptime-check-pill {{if eq .Status "down"}}is-down{{else if eq .Status "up"}}is-up{{else}}is-pending{{end}}" title="{{.Status}} · {{.CheckedAt.Format "2006-01-02 15:04:05"}}"></span>
{{end}}
{{else}}
<span class="small text-body-secondary">No checks yet.</span>
{{end}}
</div>
<div class="uptime-monitor-foot">
<span>{{.StateDurationText}}</span>
<span class="text-truncate">{{if .Monitor.LastError}}{{.Monitor.LastError}}{{else}}{{.Monitor.NodeName}}{{end}}</span>
</div>
</article>
</div>
{{end}}
<div class="uptime-chart-shell">
{{if $data.Chart.Points}}
<div class="uptime-line-chart">
<canvas
id="uptime-chart"
class="uptime-line-chart-canvas"
data-labels='{{$data.Chart.LabelsJSON}}'
data-values='{{$data.Chart.ValuesJSON}}'
data-point-colors='{{$data.Chart.PointColorsJSON}}'
aria-label="Fleet availability chart"
></canvas>
</div>
{{else}}
<div class="col-12">
<div class="text-body-secondary">No monitors yet. Add a VM to start tracking uptime.</div>
</div>
<div class="text-body-secondary"><i class="ti ti-radar-off me-2"></i>No checks yet. Add a VM or run checks.</div>
{{end}}
</div>
</div>
@@ -107,17 +69,17 @@
<div class="col-12 col-xxl-4">
<article class="card border-0 shadow-sm h-100">
<div class="card-body p-4">
<h2 class="h4 mb-4">Availability</h2>
<h2 class="h4 mb-4"><i class="ti ti-report-analytics me-2"></i>Availability</h2>
<div class="table-responsive">
<table class="table align-middle mb-0 uptime-table">
<table class="table align-middle mb-0 uptime-table uptime-table-compact">
<thead>
<tr>
<th>Period</th>
<th>Availability</th>
<th>Downtime</th>
<th>Incidents</th>
<th>Longest</th>
<th>Avg</th>
<th><i class="ti ti-calendar me-2"></i>Period</th>
<th><i class="ti ti-activity me-2"></i>Availability</th>
<th><i class="ti ti-plug-connected-x me-2"></i>Downtime</th>
<th><i class="ti ti-alert-triangle me-2"></i>Incidents</th>
<th><i class="ti ti-hourglass-high me-2"></i>Longest</th>
<th><i class="ti ti-average me-2"></i>Avg</th>
</tr>
</thead>
<tbody>
@@ -139,22 +101,84 @@
</div>
</section>
<section class="mb-4">
<article class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h2 class="h4 mb-0"><i class="ti ti-server-2 me-2"></i>Nodes</h2>
<span class="text-body-secondary small">{{len $data.Monitors}} total</span>
</div>
<div class="table-responsive">
<table class="table align-middle mb-0 uptime-table uptime-table-nodes">
<thead>
<tr>
<th><i class="ti ti-server-2 me-2"></i>Node</th>
<th><i class="ti ti-heartbeat me-2"></i>Status</th>
<th><i class="ti ti-activity me-2"></i>Availability</th>
<th><i class="ti ti-wave-sine me-2"></i>Latency</th>
<th><i class="ti ti-clock me-2"></i>Checked</th>
<th><i class="ti ti-timeline me-2"></i>Window</th>
<th><i class="ti ti-chart-bar me-2"></i>Recent</th>
</tr>
</thead>
<tbody>
{{if $data.Monitors}}
{{range $data.Monitors}}
<tr>
<td>
<div class="fw-semibold text-body-emphasis"><i class="ti ti-server-2 me-2"></i>{{.Monitor.Name}}</div>
<div class="small text-body-secondary"><i class="ti ti-world me-1"></i>{{.Monitor.Target}}{{if .Monitor.GroupName}} · {{.Monitor.GroupName}}{{end}}</div>
</td>
<td>
<span class="uptime-monitor-badge {{if eq .Monitor.LastStatus "down"}}is-down{{else if eq .Monitor.LastStatus "up"}}is-up{{else}}is-pending{{end}}">
{{if eq .Monitor.LastStatus "down"}}Down{{else if eq .Monitor.LastStatus "up"}}Up{{else}}Pending{{end}}
</span>
</td>
<td class="text-nowrap">{{.AvailabilityText}}</td>
<td class="text-nowrap">{{if .Monitor.LastLatencyMS}}{{.Monitor.LastLatencyMS}}ms{{else}}-{{end}}</td>
<td class="text-nowrap">{{.LastCheckedText}}</td>
<td class="text-nowrap">{{.StateDurationText}}</td>
<td>
<div class="uptime-check-strip uptime-check-strip-table" aria-hidden="true">
{{if .RecentChecks}}
{{range .RecentChecks}}
<span class="uptime-check-pill {{if eq .Status "down"}}is-down{{else if eq .Status "up"}}is-up{{else}}is-pending{{end}}" title="{{.Status}} · {{.CheckedAt.Format "2006-01-02 15:04:05"}}"></span>
{{end}}
{{else}}
<span class="small text-body-secondary">No checks</span>
{{end}}
</div>
</td>
</tr>
{{end}}
{{else}}
<tr>
<td colspan="7" class="text-body-secondary">No monitors yet. Add a VM to start tracking uptime.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</article>
</section>
<section>
<article class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h2 class="h4 mb-0">Incidents</h2>
<h2 class="h4 mb-0"><i class="ti ti-alert-octagon me-2"></i>Incidents</h2>
<span class="text-body-secondary small">{{len $data.Incidents}} recent</span>
</div>
<div class="table-responsive">
<table class="table align-middle mb-0 uptime-table">
<table class="table align-middle mb-0 uptime-table uptime-table-compact">
<thead>
<tr>
<th>Monitor</th>
<th>Started</th>
<th>Ended</th>
<th>Duration</th>
<th>Error</th>
<th><i class="ti ti-radar-2 me-2"></i>Monitor</th>
<th><i class="ti ti-player-play me-2"></i>Started</th>
<th><i class="ti ti-player-stop me-2"></i>Ended</th>
<th><i class="ti ti-clock-hour-4 me-2"></i>Duration</th>
<th><i class="ti ti-bug me-2"></i>Error</th>
</tr>
</thead>
<tbody>

View File

@@ -0,0 +1,192 @@
{{define "content"}}
{{$data := .Content}}
<section class="mb-4">
<div>
<div class="text-uppercase small fw-semibold text-primary mb-2"><i class="ti ti-user-cog me-1"></i>Account</div>
<h1 class="display-6 fw-bold mb-2"><i class="ti ti-settings-2 me-2"></i>User Settings</h1>
<p class="text-body-secondary mb-0">Password, 2FA, and your display mode.</p>
</div>
</section>
{{if $data.Message}}
<div class="alert alert-success border-0 shadow-sm"><i class="ti ti-circle-check me-2"></i>{{$data.Message}}</div>
{{end}}
{{if $data.Error}}
<div class="alert alert-danger border-0 shadow-sm"><i class="ti ti-alert-circle me-2"></i>{{$data.Error}}</div>
{{end}}
<section class="row g-4">
<div class="col-12 col-xl-4">
<article class="card border-0 shadow-sm settings-nav-card h-100">
<div class="card-body p-4">
<div class="d-flex align-items-center gap-3 mb-4">
<div class="brand-mark-lg">
<i class="ti ti-user"></i>
</div>
<div class="min-w-0">
<div class="fw-bold text-body-emphasis text-truncate">{{with .User}}{{.Name}}{{end}}</div>
<div class="small text-body-secondary text-truncate">{{with .User}}{{.Email}}{{end}}</div>
<div class="small text-body-secondary text-truncate text-uppercase">{{with .User}}{{.Role}}{{end}}</div>
</div>
</div>
<div class="settings-nav-list">
<a class="settings-nav-link is-active" href="#account-security"><i class="ti ti-lock"></i><span>Security</span></a>
<a class="settings-nav-link" href="#account-appearance"><i class="ti ti-sun-moon"></i><span>Appearance</span></a>
<a class="settings-nav-link" href="#account-profile"><i class="ti ti-user-circle"></i><span>Profile</span></a>
</div>
</div>
</article>
</div>
<div class="col-12 col-xl-8">
<div class="vstack gap-4">
<article class="card border-0 shadow-sm settings-section-card" id="account-profile">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<div>
<h2 class="h4 mb-1"><i class="ti ti-user-circle me-2"></i>Profile</h2>
<div class="small text-body-secondary">Current account details.</div>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="settings-info-tile">
<div class="small text-uppercase text-body-secondary fw-semibold mb-2">Name</div>
<div class="fw-semibold">{{with .User}}{{.Name}}{{end}}</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="settings-info-tile">
<div class="small text-uppercase text-body-secondary fw-semibold mb-2">Email</div>
<div class="fw-semibold">{{with .User}}{{.Email}}{{end}}</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="settings-info-tile">
<div class="small text-uppercase text-body-secondary fw-semibold mb-2">Role</div>
<div class="fw-semibold">{{with .User}}{{.Role}}{{end}}</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="settings-info-tile">
<div class="small text-uppercase text-body-secondary fw-semibold mb-2">2FA</div>
<div class="fw-semibold">{{if $data.OTPEnabled}}Enabled{{else}}Disabled{{end}}</div>
</div>
</div>
</div>
</div>
</article>
<article class="card border-0 shadow-sm settings-section-card" id="account-security">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<div>
<h2 class="h4 mb-1"><i class="ti ti-lock me-2"></i>Password</h2>
<div class="small text-body-secondary">Update your sign-in password.</div>
</div>
</div>
<form method="post" action="/settings/user/password">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Current password</label>
<input type="password" class="form-control" name="current_password" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label">New password</label>
<input type="password" class="form-control" name="new_password" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Confirm password</label>
<input type="password" class="form-control" name="confirm_password" required>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="ti ti-device-floppy me-2"></i>Save password</button>
</div>
</form>
</div>
</article>
<article class="card border-0 shadow-sm settings-section-card">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<div>
<h2 class="h4 mb-1"><i class="ti ti-shield-lock me-2"></i>Two-factor authentication</h2>
<div class="small text-body-secondary">Manage OTP authentication for your account.</div>
</div>
{{if $data.OTPEnabled}}
<span class="badge text-bg-success">Enabled</span>
{{else}}
<span class="badge text-bg-secondary">Pending</span>
{{end}}
</div>
{{if $data.OTPEnabled}}
<div class="d-flex flex-wrap gap-2">
<form method="post" action="/settings/user/2fa/reset">
<button type="submit" class="btn btn-outline-primary"><i class="ti ti-refresh me-2"></i>Reset 2FA</button>
</form>
<form method="post" action="/settings/user/2fa/disable">
<button type="submit" class="btn btn-outline-danger"><i class="ti ti-lock-off me-2"></i>Disable 2FA</button>
</form>
</div>
{{else}}
<div class="row g-4 align-items-center">
<div class="col-12 col-lg-4">
<div class="otp-qr-card text-center">
<img alt="OTP QR code" src="{{$data.OTPQRCode}}">
</div>
</div>
<div class="col-12 col-lg-8">
<form method="post" action="/settings/user/2fa/enable">
<div class="mb-3">
<label class="form-label">Secret</label>
<input type="text" class="form-control" readonly value="{{$data.OTPSecret}}">
</div>
<div class="mb-4">
<label class="form-label">Authenticator code</label>
<input type="text" class="form-control" name="code" inputmode="numeric" required>
</div>
<button type="submit" class="btn btn-primary"><i class="ti ti-shield-check me-2"></i>Enable 2FA</button>
</form>
</div>
</div>
{{end}}
</div>
</article>
<article class="card border-0 shadow-sm settings-section-card" id="account-appearance">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<div>
<h2 class="h4 mb-1"><i class="ti ti-sun-moon me-2"></i>Appearance</h2>
<div class="small text-body-secondary">Choose how Maintainarr looks for you.</div>
</div>
</div>
<form method="post" action="/settings/user/theme">
<div class="row g-3 mb-4">
<div class="col-12 col-md-6">
<input type="radio" class="btn-check" name="mode" id="user-theme-dark" value="dark" {{if eq $data.CurrentMode "dark"}}checked{{end}}>
<label class="theme-card d-block h-100" for="user-theme-dark">
<span class="theme-swatch swatch-dark"></span>
<strong>Dark</strong>
<small>Default night view</small>
</label>
</div>
<div class="col-12 col-md-6">
<input type="radio" class="btn-check" name="mode" id="user-theme-light" value="light" {{if eq $data.CurrentMode "light"}}checked{{end}}>
<label class="theme-card d-block h-100" for="user-theme-light">
<span class="theme-swatch swatch-light"></span>
<strong>Light</strong>
<small>Bright daytime view</small>
</label>
</div>
</div>
<button type="submit" class="btn btn-primary"><i class="ti ti-device-floppy me-2"></i>Save appearance</button>
</form>
</div>
</article>
</div>
</div>
</section>
{{end}}

View File

@@ -404,7 +404,7 @@ body[data-bs-theme="light"] .app-nav-link.is-active {
min-height: 12rem;
display: flex;
flex-direction: column;
justify-content: space-between;
justify-content: flex-start;
}
.kpi-card-label {
@@ -415,26 +415,6 @@ body[data-bs-theme="light"] .app-nav-link.is-active {
color: var(--bs-secondary-color);
}
.kpi-card-subtle {
margin-top: 0.35rem;
font-size: 0.9rem;
color: var(--bs-secondary-color);
}
.kpi-card-chip {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--ma-border);
background: color-mix(in srgb, var(--ma-surface-base) 78%, var(--ma-surface-2));
color: var(--bs-secondary-color);
font-size: 0.76rem;
font-weight: 600;
white-space: nowrap;
}
.kpi-card-featured-value {
position: relative;
z-index: 1;
@@ -443,51 +423,13 @@ body[data-bs-theme="light"] .app-nav-link.is-active {
line-height: 1;
letter-spacing: -0.03em;
color: var(--bs-emphasis-color);
}
.kpi-card-featured-trend {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
}
.kpi-card-trend {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.3rem 0.65rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
}
.kpi-card-trend.is-up {
color: #86efac;
background: rgba(20, 83, 45, 0.58);
}
.kpi-card-trend.is-down {
color: #fca5a5;
background: rgba(127, 29, 29, 0.58);
}
.kpi-card-trend.is-flat {
color: #cbd5e1;
background: rgba(51, 65, 85, 0.6);
}
.kpi-card-trend-copy {
font-size: 0.88rem;
color: var(--bs-secondary-color);
margin-top: auto;
}
.kpi-graph {
position: absolute;
inset: 0;
opacity: 0.55;
opacity: 1;
pointer-events: none;
}
@@ -498,9 +440,8 @@ body[data-bs-theme="light"] .app-nav-link.is-active {
.kpi-card-featured .kpi-graph {
inset: auto 0 0 0;
height: 68%;
opacity: 0.92;
mask-image: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.45) 26%, rgba(0, 0, 0, 1) 100%);
height: 42%;
opacity: 0.96;
}
.node-tile {
@@ -563,9 +504,10 @@ body[data-bs-theme="light"] .app-nav-link.is-active {
}
.node-brand-icon {
width: 3rem;
height: 3rem;
font-size: 2.1rem;
width: 100%;
height: 80%;
font-size: 3.6rem;
align-self: center;
}
.node-loading-icon {
@@ -680,6 +622,21 @@ body[data-bs-theme="light"] .app-nav-link.is-active {
font-size: 0.92rem;
}
.system-summary-copy {
width: 100%;
padding: 0.55rem 0.7rem;
border: 0;
border-radius: 0.9rem;
background: transparent;
text-align: left;
transition: background-color 0.18s ease, transform 0.18s ease;
}
.system-summary-copy:hover {
background: color-mix(in srgb, var(--ma-surface-base) 72%, var(--ma-surface-2));
transform: translateX(2px);
}
.progress {
--bs-progress-height: 0.6rem;
background: color-mix(in srgb, black 18%, var(--ma-surface-1));
@@ -875,17 +832,47 @@ body[data-bs-theme="light"] .option-check {
height: 100vh;
width: 100vw;
overflow: hidden;
background: #0a0f1a;
}
.console-page {
height: 100vh;
width: 100vw;
background:
radial-gradient(circle at top, rgba(var(--color-primary-700), 0.18), transparent 22%),
linear-gradient(180deg, #070b13 0%, #0a0f1a 100%);
}
.console-page-terminal {
height: 100%;
width: 100%;
overflow: hidden;
}
.console-terminal-fullscreen {
min-height: 100vh;
height: 100vh;
max-height: none;
border-radius: 0;
background: transparent;
}
.console-terminal .xterm {
height: 100%;
background: #0b1220;
padding: 0.9rem 1rem;
padding: 0;
}
.console-terminal .xterm-viewport {
background: #0b1220;
border-radius: 1.25rem;
border-radius: 0;
}
.console-terminal-fullscreen .xterm,
.console-terminal-fullscreen .xterm-screen,
.console-terminal-fullscreen .xterm-viewport,
.console-terminal-fullscreen .xterm-scroll-area {
background: #0a0f1a;
}
.preview-card {
@@ -957,6 +944,61 @@ body[data-bs-theme="light"] .theme-card {
box-shadow: 0 0 0 0.2rem rgba(var(--color-primary-500), 0.2);
}
.settings-nav-card,
.settings-section-card,
.settings-info-tile,
.otp-qr-card {
background: linear-gradient(180deg, color-mix(in srgb, var(--ma-surface-2) 90%, transparent), var(--ma-surface-1));
}
.settings-nav-list {
display: grid;
gap: 0.55rem;
}
.settings-nav-link {
display: flex;
align-items: center;
gap: 0.85rem;
min-height: 2.9rem;
padding: 0.8rem 0.95rem;
border: 1px solid var(--ma-border);
border-radius: 0.95rem;
color: var(--bs-secondary-color);
font-weight: 600;
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.settings-nav-link:hover,
.settings-nav-link.is-active {
color: var(--bs-emphasis-color);
background: color-mix(in srgb, rgb(var(--color-primary-500)) 8%, transparent);
border-color: rgba(var(--color-primary-500), 0.35);
}
.settings-nav-link i {
font-size: 1.15rem;
}
.settings-info-tile {
min-height: 100%;
padding: 1rem;
border: 1px solid var(--ma-border);
border-radius: 1rem;
}
.otp-qr-card {
padding: 1rem;
border: 1px solid var(--ma-border);
border-radius: 1rem;
}
.otp-qr-card img {
width: 100%;
max-width: 220px;
height: auto;
}
.uptime-summary-card {
background: linear-gradient(180deg, color-mix(in srgb, var(--ma-surface-2) 92%, transparent), var(--ma-surface-1));
}
@@ -977,6 +1019,27 @@ body[data-bs-theme="light"] .theme-card {
color: var(--bs-emphasis-color);
}
.uptime-chart-shell {
display: grid;
gap: 0.75rem;
}
.uptime-line-chart {
position: relative;
height: 18rem;
padding: 0.75rem;
border-radius: 1rem;
background: linear-gradient(180deg, color-mix(in srgb, var(--ma-surface-base) 84%, var(--ma-surface-2)), color-mix(in srgb, var(--ma-surface-1) 96%, transparent));
overflow: hidden;
border: 1px solid var(--ma-border);
}
.uptime-line-chart-canvas {
display: block;
width: 100%;
height: 100%;
}
.uptime-monitor-card {
display: grid;
gap: 1rem;
@@ -1064,6 +1127,12 @@ body[data-bs-theme="light"] .theme-card {
padding: 0.2rem 0;
}
.uptime-check-strip-table {
min-width: 10rem;
min-height: 1rem;
gap: 0.18rem;
}
.uptime-check-pill {
flex: 1 1 0;
min-width: 0;
@@ -1084,6 +1153,11 @@ body[data-bs-theme="light"] .theme-card {
background: rgba(100, 116, 139, 0.35);
}
.uptime-check-strip-table .uptime-check-pill {
height: 0.8rem;
min-width: 0.2rem;
}
.uptime-monitor-foot {
font-size: 0.82rem;
}
@@ -1094,6 +1168,37 @@ body[data-bs-theme="light"] .theme-card {
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--bs-secondary-color);
border-bottom: 1px solid var(--ma-border);
padding: 0 0.85rem 0.85rem;
white-space: nowrap;
}
.uptime-table td {
padding: 0.9rem 0.85rem;
border-color: var(--ma-border);
vertical-align: middle;
}
.uptime-table tbody tr {
transition: background-color 0.18s ease;
}
.uptime-table tbody tr:hover {
background: color-mix(in srgb, var(--ma-surface-base) 72%, var(--ma-surface-2));
}
.uptime-table tbody tr:last-child td {
border-bottom-color: transparent;
}
.uptime-table-compact td,
.uptime-table-compact th {
padding-left: 0.7rem;
padding-right: 0.7rem;
}
.uptime-table-nodes td {
font-size: 0.92rem;
}
body[data-bs-theme="light"] .uptime-monitor-card,
@@ -1105,6 +1210,10 @@ body[data-bs-theme="light"] .uptime-monitor-stat {
background: color-mix(in srgb, white 82%, var(--ma-surface-1));
}
body[data-bs-theme="light"] .uptime-line-chart {
background: linear-gradient(180deg, color-mix(in srgb, white 86%, var(--ma-surface-1)), var(--ma-surface-1));
}
@media (max-width: 991.98px) {
.app-shell {
flex-direction: column;

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -10,6 +10,7 @@ document.addEventListener("DOMContentLoaded", () => {
const authToggle = document.querySelector("[data-auth-toggle]");
const themeRadios = document.querySelectorAll('input[name="theme"]');
const themeModeInput = document.querySelector('input[name="mode"]');
const uptimeChart = document.getElementById("uptime-chart");
const attachXtermConsole = (consoleOutput) => {
const wsPath = consoleOutput.dataset.ws;
@@ -145,7 +146,6 @@ document.addEventListener("DOMContentLoaded", () => {
disk: [],
uptime: [],
};
let previousCpu = null;
const uptimeLabel = (seconds) => {
const days = Math.floor(seconds / 86400);
@@ -155,32 +155,44 @@ document.addEventListener("DOMContentLoaded", () => {
return `${hours}h ${minutes}m`;
};
const renderSparkline = (values) => {
const renderSparkline = (values, key) => {
if (values.length === 0) {
return "";
}
const max = Math.max(...values, 1);
const points = values.map((value, index) => {
const x = (index / Math.max(values.length - 1, 1)) * 100;
const y = 100 - (value / max) * 80 - 10;
return `${x},${y}`;
let min = 0;
let max = 100;
if (key === "uptime") {
min = Math.min(...values);
max = Math.max(...values);
if (max-min < 5) {
max = min + 5;
}
}
const path = values.map((value, index) => {
const x = values.length === 1 ? 0 : (index / (values.length - 1)) * 100;
const normalized = (value - min) / Math.max(max - min, 1);
const y = 90 - normalized * 72;
return `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`;
}).join(" ");
return `
<svg viewBox="0 0 100 100" preserveAspectRatio="none">
<polyline points="${points}" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></polyline>
<path d="${path}" fill="none" stroke="rgb(var(--color-primary-500))" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"></path>
</svg>
`;
};
const pushValue = (key, value) => {
history[key].push(value);
if (history[key].length > 18) {
if (history[key].length > 60) {
history[key].shift();
}
const card = nodeLive.querySelector(`.kpi-card[data-kpi="${key}"] .kpi-graph`);
if (card) {
card.innerHTML = renderSparkline(history[key]);
card.innerHTML = renderSparkline(history[key], key);
}
};
@@ -203,21 +215,11 @@ document.addEventListener("DOMContentLoaded", () => {
const ramValue = nodeLive.querySelector('[data-kpi-value="ram"]');
const diskValue = nodeLive.querySelector('[data-kpi-value="disk"]');
const uptimeValue = nodeLive.querySelector('[data-kpi-value="uptime"]');
const cpuTrend = nodeLive.querySelector('[data-kpi-trend="cpu"]');
if (cpuValue) cpuValue.textContent = `${cpu.toFixed(1)}%`;
if (ramValue) ramValue.textContent = `${ram.toFixed(1)}%`;
if (diskValue) diskValue.textContent = `${disk.toFixed(1)}%`;
if (uptimeValue) uptimeValue.textContent = uptimeLabel(uptime);
if (cpuTrend) {
const delta = previousCpu === null ? 0 : cpu - previousCpu;
const trendClass = delta > 0.15 ? "is-up" : delta < -0.15 ? "is-down" : "is-flat";
const trendLabel = `${delta > 0 ? "+" : ""}${delta.toFixed(1)}%`;
cpuTrend.classList.remove("is-up", "is-down", "is-flat");
cpuTrend.classList.add(trendClass);
cpuTrend.textContent = trendLabel;
}
previousCpu = cpu;
pushValue("cpu", cpu);
pushValue("ram", ram);
@@ -317,6 +319,133 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
if (uptimeChart instanceof HTMLCanvasElement && typeof Chart !== "undefined") {
try {
const labels = JSON.parse(uptimeChart.dataset.labels || "[]");
const values = JSON.parse(uptimeChart.dataset.values || "[]");
const pointColors = JSON.parse(uptimeChart.dataset.pointColors || "[]");
const style = getComputedStyle(document.body);
const primaryRGB = style.getPropertyValue("--color-primary-500").trim() || "16 185 129";
const chartStroke = `rgb(${primaryRGB})`;
const chartFill = `rgba(${primaryRGB.replace(/\s+/g, ", ")}, 0.16)`;
const gridColor = style.getPropertyValue("--ma-border").trim() || "rgba(148, 163, 184, 0.16)";
const tickColor = style.getPropertyValue("--bs-secondary-color").trim() || "#94a3b8";
new Chart(uptimeChart, {
type: "line",
data: {
labels,
datasets: [{
data: values,
borderColor: chartStroke,
backgroundColor: chartFill,
fill: true,
borderWidth: 3,
tension: 0.35,
pointRadius: 3,
pointHoverRadius: 4,
pointBackgroundColor: pointColors,
pointBorderColor: pointColors,
pointBorderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
display: false,
},
tooltip: {
displayColors: false,
callbacks: {
label(context) {
return `${Number(context.parsed.y || 0).toFixed(0)}% availability`;
},
},
},
},
interaction: {
intersect: false,
mode: "index",
},
scales: {
x: {
grid: {
display: false,
},
border: {
display: false,
},
ticks: {
color: tickColor,
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 6,
},
},
y: {
min: 0,
max: 100,
border: {
display: false,
},
ticks: {
color: tickColor,
stepSize: 25,
callback(value) {
return `${value}%`;
},
},
grid: {
color: gridColor,
drawTicks: false,
},
},
},
},
});
} catch (_) {
// Ignore malformed chart payloads.
}
}
document.addEventListener("click", async (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const copyTrigger = target.closest("[data-copy-value]");
if (!(copyTrigger instanceof HTMLElement)) {
return;
}
const value = (copyTrigger.dataset.copyValue || "").trim();
if (!value) {
return;
}
try {
await navigator.clipboard.writeText(value);
} catch (_) {
return;
}
if (typeof bootstrap !== "undefined") {
const tooltip = bootstrap.Tooltip.getOrCreateInstance(copyTrigger);
const previousTitle = copyTrigger.getAttribute("data-bs-title") || copyTrigger.getAttribute("title") || "Copy";
copyTrigger.setAttribute("data-bs-title", "Copied");
tooltip.setContent?.({ ".tooltip-inner": "Copied" });
tooltip.show();
window.setTimeout(() => {
copyTrigger.setAttribute("data-bs-title", previousTitle);
tooltip.setContent?.({ ".tooltip-inner": previousTitle });
}, 1200);
}
});
if (typeof bootstrap !== "undefined") {
document.querySelectorAll('[data-bs-toggle-tooltip="tooltip"]').forEach((element) => {
new bootstrap.Tooltip(element);