feat(ui): add user settings and refine dashboard experience
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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 '';`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,6 +27,7 @@ type User struct {
|
||||
Role Role
|
||||
OTPSecret string
|
||||
OTPEnabled bool
|
||||
ThemeMode string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, `
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
192
internal/views/pages/user_settings.gohtml
Normal file
192
internal/views/pages/user_settings.gohtml
Normal 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}}
|
||||
@@ -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;
|
||||
|
||||
BIN
web/static/img/favicon-rounded.png
Normal file
BIN
web/static/img/favicon-rounded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user