Merge pull request 'dev' (#8) from dev into main
Some checks failed
Verify / verify (push) Waiting to run
Release Container / Build And Publish Container (push) Failing after 57s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-06-21 02:02:34 +00:00
33 changed files with 5328 additions and 491 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.git
.gitea
data
tmp
dist
bin
*.log
*.db
*.db-shm
*.db-wal
coverage.out
node_modules

View File

@@ -1,5 +1,6 @@
MAINTAINARR_ADDR=:8080
MAINTAINARR_DB_PATH=data/maintainarr.db
MAINTAINARR_LOG_ARCHIVE_DIR=data/log-archives
MAINTAINARR_SESSION_KEY=change-me-session-key-please
MAINTAINARR_ENCRYPTION_KEY=change-me-encryption-key-32bytes
MAINTAINARR_ORG_NAME=Maintainarr

View File

@@ -0,0 +1,235 @@
name: Release Container
on:
push:
branches:
- main
env:
APP_NAME: maintainarr
jobs:
publish-container:
name: Build And Publish Container
runs-on: ubuntu-latest
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SHA: ${{ gitea.sha }}
GITEA_ACTOR: ${{ gitea.actor }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_REGISTRY: ${{ secrets.GITEA_REGISTRY }}
GITEA_REGISTRY_USERNAME: ${{ secrets.GITEA_REGISTRY_USERNAME }}
GITEA_PACKAGE_NAMESPACE: ${{ secrets.GITEA_PACKAGE_NAMESPACE }}
steps:
- name: Check out repository
uses: https://dock-it.dev/actions/checkout@v4
with:
fetch-depth: 0
- name: Prepare release metadata
shell: bash
run: |
set -euo pipefail
short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)"
repo_owner="${GITEA_REPOSITORY%%/*}"
registry_host="${GITEA_REGISTRY}"
if [ -z "$registry_host" ]; then
registry_host="$(printf '%s' "$GITEA_SERVER_URL" | sed -E 's#^https?://##; s#/$##')"
fi
package_namespace="${GITEA_PACKAGE_NAMESPACE}"
if [ -z "$package_namespace" ]; then
package_namespace="${repo_owner}"
fi
registry_username="${GITEA_REGISTRY_USERNAME}"
if [ -z "$registry_username" ]; then
registry_username="${GITEA_ACTOR}"
fi
image_ref="${registry_host}/${package_namespace}/${APP_NAME}"
echo "SHORT_SHA=${short_sha}" >> "$GITHUB_ENV"
echo "RELEASE_TAG=release-${short_sha}" >> "$GITHUB_ENV"
echo "RELEASE_NAME=Release ${short_sha}" >> "$GITHUB_ENV"
echo "REGISTRY_HOST=${registry_host}" >> "$GITHUB_ENV"
echo "REGISTRY_USERNAME=${registry_username}" >> "$GITHUB_ENV"
echo "PACKAGE_NAMESPACE=${package_namespace}" >> "$GITHUB_ENV"
echo "IMAGE_REF=${image_ref}" >> "$GITHUB_ENV"
- name: Verify release token
shell: bash
run: |
set -euo pipefail
if [ -z "$GITEA_TOKEN" ]; then
echo "The repository secret GITEA_TOKEN is required to publish releases and packages."
exit 1
fi
- name: Install release dependencies
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y curl jq
- name: Log in to Gitea container registry
shell: bash
run: |
set -euo pipefail
printf '%s' "$GITEA_TOKEN" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin
- name: Build container image
shell: bash
run: |
set -euo pipefail
docker build \
--tag "${IMAGE_REF}:${SHORT_SHA}" \
--tag "${IMAGE_REF}:main" \
--tag "${IMAGE_REF}:latest" \
.
- name: Push container image
shell: bash
run: |
set -euo pipefail
docker push "${IMAGE_REF}:${SHORT_SHA}"
docker push "${IMAGE_REF}:main"
docker push "${IMAGE_REF}:latest"
- name: Create release notes
shell: bash
run: |
set -euo pipefail
git fetch --tags --force
api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}"
repo_url="${GITEA_SERVER_URL%/}/${GITEA_REPOSITORY}"
previous_tag="$(
curl --fail-with-body --silent --show-error \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api}/releases?limit=50" |
jq -r '[.[] | select(.tag_name | startswith("release-"))][0].tag_name // empty'
)"
if [ -n "$previous_tag" ]; then
range="${previous_tag}..${GITEA_SHA}"
else
range="${GITEA_SHA}"
fi
{
if [ -n "$previous_tag" ]; then
echo "## Changes since ${previous_tag}"
else
echo "## Changes"
fi
echo
commit_count="$(git rev-list --count "$range")"
if [ "$commit_count" -eq 0 ]; then
echo "- No commits found since the previous release."
else
git log "$range" \
--reverse \
--pretty=format:'%H%x1f%s' |
while IFS="$(printf '\037')" read -r hash subject; do
short="$(printf '%s' "$hash" | cut -c1-7)"
echo "- ([${short}](${repo_url}/commit/${hash})) ${subject}"
done
fi
echo
echo "## Container Images"
echo
echo "- \`${IMAGE_REF}:${SHORT_SHA}\`"
echo "- \`${IMAGE_REF}:main\`"
echo "- \`${IMAGE_REF}:latest\`"
echo
echo "## Authors"
echo
echo "Sorted by total lines added or removed."
echo
git log "$range" --numstat --format='author:%an <%ae>' |
awk '
/^author:/ {
author = substr($0, 8)
next
}
NF >= 3 {
added = ($1 == "-" ? 0 : $1)
removed = ($2 == "-" ? 0 : $2)
adds[author] += added
dels[author] += removed
churn[author] += added + removed
}
END {
for (a in churn) {
printf "%d\t%d\t%d\t%s\n", churn[a], adds[a], dels[a], a
}
}
' |
sort -nr |
while IFS="$(printf '\t')" read -r total added removed author; do
echo "- ${author} - ${total} lines changed (+${added} / -${removed})"
done
} > release-notes.md
- name: Create Gitea release
shell: bash
run: |
set -euo pipefail
api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}"
existing_release_id="$(
curl --fail-with-body --silent --show-error \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api}/releases/tags/${RELEASE_TAG}" |
jq -r '.id // empty' 2>/dev/null || true
)"
payload="$(jq -n \
--arg tag "$RELEASE_TAG" \
--arg sha "$GITEA_SHA" \
--arg name "$RELEASE_NAME" \
--rawfile body release-notes.md \
'{
tag_name: $tag,
target_commitish: $sha,
name: $name,
body: $body,
draft: false,
prerelease: false
}')"
if [ -n "$existing_release_id" ]; then
curl --fail-with-body --silent --show-error \
-X PATCH \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data "$payload" \
"${api}/releases/${existing_release_id}" >/dev/null
else
curl --fail-with-body --silent --show-error \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data "$payload" \
"${api}/releases" >/dev/null
fi

View File

@@ -12,10 +12,10 @@ jobs:
steps:
- name: Checkout code
uses: https://dock-it.dev/actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: https://dock-it.dev/actions/checkout@v4
- name: Set up Go
uses: https://dock-it.dev/actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: https://dock-it.dev/actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM golang:1.25-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
COPY web ./web
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/maintainarr ./cmd/maintainarr
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/maintainarr /app/maintainarr
COPY web/static /app/web/static
RUN mkdir -p /app/data
ENV MAINTAINARR_ADDR=:8080
ENV MAINTAINARR_DB_PATH=/app/data/maintainarr.db
ENV MAINTAINARR_LOG_ARCHIVE_DIR=/app/data/log-archives
ENV MAINTAINARR_BASE_URL=http://localhost:8080
EXPOSE 8080
CMD ["/app/maintainarr"]

View File

@@ -5,6 +5,7 @@ Maintainarr is a self-hosted Go application for managing a single organization's
It ships with:
- SQLite-backed persistence
- Daily compressed command log archives
- User registration and login
- OTP-based 2FA setup and verification
- Role model with `admin`, `editor`, and `viewer`
@@ -55,6 +56,13 @@ go run ./cmd/maintainarr
Default address: `http://localhost:8080`
## Container
```powershell
docker build -t maintainarr .
docker run --rm -p 8080:8080 -v ${PWD}/data:/app/data maintainarr
```
## First User
The first registered user becomes the initial `admin`.
@@ -66,6 +74,7 @@ No example users, groups, nodes, or jobs are seeded.
```env
MAINTAINARR_ADDR=:8080
MAINTAINARR_DB_PATH=data/maintainarr.db
MAINTAINARR_LOG_ARCHIVE_DIR=data/log-archives
MAINTAINARR_SESSION_KEY=change-me-session-key-please
MAINTAINARR_ENCRYPTION_KEY=change-me-encryption-key-32bytes
MAINTAINARR_ORG_NAME=Maintainarr
@@ -75,6 +84,19 @@ MAINTAINARR_THEME_MODE=dark
MAINTAINARR_REFRESH_CRON=@every 5s
```
## Release Automation
- Push or merge into `main` to trigger `.gitea/workflows/release.yml`
- The workflow builds a Docker image, publishes it to the Gitea container registry, and creates or updates a Gitea release
- It tags the image as `latest`, `main`, and the short commit SHA
- Required secret: `GITEA_TOKEN`
- Optional secret: `GITEA_REGISTRY`
Defaults to the host from `gitea.server_url`
- Optional secret: `GITEA_REGISTRY_USERNAME`
Defaults to `gitea.actor`
- Optional secret: `GITEA_PACKAGE_NAMESPACE`
Defaults to the repository owner from `gitea.repository`
## Roles
- `admin`: full access, intended for user management and future organization settings

View File

@@ -96,21 +96,39 @@ func New() (*App, error) {
protected.Get("/nodes/{nodeID}/console/ws", handler.NodeConsoleWebSocket)
protected.Get("/groups", handler.GroupsPage)
protected.Get("/automations", handler.AutomationsPage)
protected.Get("/updates", handler.UpdatesPage)
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))
editor.Post("/groups", handler.CreateGroup)
editor.Post("/groups/{groupID}", handler.UpdateGroup)
editor.Post("/nodes", handler.CreateNode)
editor.Post("/nodes/{nodeID}", handler.UpdateNode)
editor.Post("/nodes/{nodeID}/actions/{action}", handler.NodeAction)
editor.Post("/nodes/{nodeID}/commands", handler.NodeQuickCommand)
editor.Post("/nodes/{nodeID}/delete", handler.DeleteNode)
editor.Post("/automations", handler.CreateAutomation)
editor.Post("/updates/scan", handler.ScanAllUpdates)
editor.Post("/updates/apply", handler.ApplyAllUpdates)
editor.Post("/updates/settings/window", handler.UpdateGlobalUpdateWindow)
editor.Post("/updates/nodes/{nodeID}/policy", handler.UpdateNodePolicy)
editor.Post("/updates/nodes/{nodeID}/scan", handler.ScanNodeUpdates)
editor.Post("/updates/nodes/{nodeID}/apply", handler.ApplyNodeUpdates)
editor.Post("/updates/groups/{groupID}/policy", handler.UpdateGroupPolicy)
editor.Post("/updates/groups/{groupID}/scan", handler.ScanGroupUpdates)
editor.Post("/updates/groups/{groupID}/apply", handler.ApplyGroupUpdates)
editor.Post("/uptime/run", handler.RunUptimeChecks)
})
})
scheduler := services.NewSchedulerService(database, nodes)
scheduler := services.NewSchedulerService(database, nodes, cfg.LogArchiveDir)
if err := scheduler.Start(context.Background(), org.ID, cfg.RefreshCron); err != nil {
return nil, err
}

View File

@@ -8,6 +8,7 @@ import (
type Config struct {
Address string
DatabasePath string
LogArchiveDir string
SessionKey string
EncryptionKey string
OrgName string
@@ -21,11 +22,12 @@ func Load() Config {
return Config{
Address: env("MAINTAINARR_ADDR", ":8080"),
DatabasePath: env("MAINTAINARR_DB_PATH", filepath.Join("data", "maintainarr.db")),
LogArchiveDir: env("MAINTAINARR_LOG_ARCHIVE_DIR", filepath.Join("data", "log-archives")),
SessionKey: env("MAINTAINARR_SESSION_KEY", "change-me-session-key-please"),
EncryptionKey: env("MAINTAINARR_ENCRYPTION_KEY", "change-me-encryption-key-32bytes"),
OrgName: env("MAINTAINARR_ORG_NAME", "Maintainarr"),
BaseURL: env("MAINTAINARR_BASE_URL", "http://localhost:8080"),
DefaultTheme: env("MAINTAINARR_THEME", "blue"),
DefaultTheme: env("MAINTAINARR_THEME", "dark"),
DefaultMode: env("MAINTAINARR_THEME_MODE", "dark"),
RefreshCron: env("MAINTAINARR_REFRESH_CRON", "@every 5s"),
}

View File

@@ -46,6 +46,9 @@ func migrate(ctx context.Context, database *sql.DB) error {
name TEXT NOT NULL,
theme TEXT NOT NULL DEFAULT 'emerald',
theme_mode TEXT NOT NULL DEFAULT 'dark',
auto_update_window_start TEXT NOT NULL DEFAULT '',
auto_update_window_end TEXT NOT NULL DEFAULT '',
auto_update_days TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE TABLE IF NOT EXISTS users (
@@ -57,6 +60,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)
);`,
@@ -66,6 +70,7 @@ func migrate(ctx context.Context, database *sql.DB) error {
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color_token TEXT NOT NULL DEFAULT 'primary',
icon TEXT NOT NULL DEFAULT 'ti ti-stack-2',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
);`,
@@ -97,7 +102,15 @@ func migrate(ctx context.Context, database *sql.DB) error {
disk_usage REAL NOT NULL DEFAULT 0,
uptime_seconds INTEGER NOT NULL DEFAULT 0,
last_seen_at DATETIME,
updates_scan_enabled BOOLEAN NOT NULL DEFAULT 1,
auto_updates_enabled BOOLEAN NOT NULL DEFAULT 0,
updates_available INTEGER NOT NULL DEFAULT 0,
updates_details TEXT NOT NULL DEFAULT '',
auto_update_window_start TEXT NOT NULL DEFAULT '',
auto_update_window_end TEXT NOT NULL DEFAULT '',
auto_update_days TEXT NOT NULL DEFAULT '',
updates_last_checked_at DATETIME,
updates_last_error TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -127,6 +140,7 @@ func migrate(ctx context.Context, database *sql.DB) error {
job_id INTEGER,
node_id INTEGER NOT NULL,
action TEXT NOT NULL,
command_text TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
output TEXT NOT NULL DEFAULT '',
triggered_by INTEGER,
@@ -136,6 +150,54 @@ func migrate(ctx context.Context, database *sql.DB) error {
FOREIGN KEY (node_id) REFERENCES nodes(id),
FOREIGN KEY (triggered_by) REFERENCES users(id)
);`,
`CREATE TABLE IF NOT EXISTS uptime_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id INTEGER NOT NULL,
node_id INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL,
target TEXT NOT NULL,
monitor_type TEXT NOT NULL DEFAULT 'ssh',
interval_seconds INTEGER NOT NULL DEFAULT 60,
enabled BOOLEAN NOT NULL DEFAULT 1,
last_status TEXT NOT NULL DEFAULT 'unknown',
last_latency_ms INTEGER NOT NULL DEFAULT 0,
last_checked_at DATETIME,
last_error TEXT NOT NULL DEFAULT '',
up_since_at DATETIME,
current_outage_started_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id),
FOREIGN KEY (node_id) REFERENCES nodes(id)
);`,
`CREATE TABLE IF NOT EXISTS uptime_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_id INTEGER NOT NULL,
status TEXT NOT NULL,
latency_ms INTEGER NOT NULL DEFAULT 0,
error_message TEXT NOT NULL DEFAULT '',
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (monitor_id) REFERENCES uptime_monitors(id)
);`,
`CREATE TABLE IF NOT EXISTS uptime_incidents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_id INTEGER NOT NULL,
error_message TEXT NOT NULL DEFAULT '',
started_at DATETIME NOT NULL,
ended_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (monitor_id) REFERENCES uptime_monitors(id)
);`,
`CREATE TABLE IF NOT EXISTS log_archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id INTEGER NOT NULL,
archive_date TEXT NOT NULL,
file_path TEXT NOT NULL,
entry_count INTEGER NOT NULL DEFAULT 0,
compressed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (organization_id, archive_date),
FOREIGN KEY (organization_id) REFERENCES organizations(id)
);`,
}
for _, statement := range statements {
@@ -146,6 +208,11 @@ 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 organizations ADD COLUMN auto_update_window_start TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE organizations ADD COLUMN auto_update_window_end TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE organizations ADD COLUMN auto_update_days TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE users ADD COLUMN theme_mode TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE vm_groups ADD COLUMN icon TEXT NOT NULL DEFAULT 'ti ti-stack-2';`,
`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 '';`,
@@ -157,7 +224,18 @@ func migrate(ctx context.Context, database *sql.DB) error {
`ALTER TABLE nodes ADD COLUMN package_count INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE nodes ADD COLUMN memory_total_mb INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE nodes ADD COLUMN disk_total_gb INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE nodes ADD COLUMN updates_scan_enabled BOOLEAN NOT NULL DEFAULT 1;`,
`ALTER TABLE nodes ADD COLUMN updates_available INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE nodes ADD COLUMN updates_details TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN auto_update_window_start TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN auto_update_window_end TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN auto_update_days TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN updates_last_checked_at DATETIME;`,
`ALTER TABLE nodes ADD COLUMN updates_last_error TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE automation_jobs ADD COLUMN tag TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE command_runs ADD COLUMN command_text TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE uptime_monitors ADD COLUMN last_error TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE uptime_monitors ADD COLUMN up_since_at DATETIME;`,
}
for _, statement := range alterStatements {

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,9 @@ type Organization struct {
Name string
Theme string
ThemeMode string
AutoUpdateWindowStart string
AutoUpdateWindowEnd string
AutoUpdateDays string
CreatedAt time.Time
}
@@ -27,6 +30,7 @@ type User struct {
Role Role
OTPSecret string
OTPEnabled bool
ThemeMode string
CreatedAt time.Time
}
@@ -36,6 +40,7 @@ type VMGroup struct {
Name string
Description string
ColorToken string
Icon string
CreatedAt time.Time
}
@@ -68,12 +73,27 @@ type Node struct {
DiskUsage float64
UptimeSeconds int64
LastSeenAt *time.Time
UpdatesScanEnabled bool
AutoUpdatesEnabled bool
UpdatesAvailable int64
UpdatesDetails string
AutoUpdateWindowStart string
AutoUpdateWindowEnd string
AutoUpdateDays string
UpdatesLastChecked *time.Time
UpdatesLastError string
Notes string
CreatedAt time.Time
UpdatedAt time.Time
}
type NodeUpdatePackage struct {
Name string
Current string
Available string
Architecture string
}
type AutomationJob struct {
ID int64
OrganizationID int64
@@ -98,7 +118,9 @@ type CommandRun struct {
NodeID int64
JobName string
NodeName string
GroupName string
Action string
CommandText string
Status string
Output string
TriggeredBy *int64
@@ -106,3 +128,57 @@ type CommandRun struct {
FinishedAt *time.Time
DurationText string
}
type UptimeMonitor struct {
ID int64
OrganizationID int64
NodeID int64
NodeName string
GroupName string
Name string
Target string
MonitorType string
IntervalSeconds int64
Enabled bool
LastStatus string
LastLatencyMS int64
LastCheckedAt *time.Time
LastError string
UpSinceAt *time.Time
CurrentOutageStartedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type UptimeCheck struct {
ID int64
MonitorID int64
Status string
LatencyMS int64
ErrorMessage string
CheckedAt time.Time
}
type UptimeIncident struct {
ID int64
MonitorID int64
MonitorName string
NodeName string
GroupName string
ErrorMessage string
StartedAt time.Time
EndedAt *time.Time
DurationSeconds int64
DurationText string
}
type UptimePeriodSummary struct {
TotalChecks int64
UpChecks int64
DownChecks int64
AvgLatencyMS int64
DowntimeSeconds int64
IncidentCount int64
LongestIncidentSeconds int64
AvgIncidentSeconds int64
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,10 @@ func (r *Repository) DB() *sql.DB {
func (r *Repository) EnsureOrganization(ctx context.Context, name, theme string) (models.Organization, error) {
var organization models.Organization
err := r.db.QueryRowContext(ctx, `
SELECT id, name, theme, theme_mode, created_at
SELECT id, name, theme, theme_mode, auto_update_window_start, auto_update_window_end, auto_update_days, created_at
FROM organizations
LIMIT 1
`).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.CreatedAt)
`).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.AutoUpdateWindowStart, &organization.AutoUpdateWindowEnd, &organization.AutoUpdateDays, &organization.CreatedAt)
if err == nil {
return organization, nil
}
@@ -47,10 +47,10 @@ func (r *Repository) EnsureOrganization(ctx context.Context, name, theme string)
func (r *Repository) GetOrganization(ctx context.Context) (models.Organization, error) {
var organization models.Organization
err := r.db.QueryRowContext(ctx, `
SELECT id, name, theme, theme_mode, created_at
SELECT id, name, theme, theme_mode, auto_update_window_start, auto_update_window_end, auto_update_days, created_at
FROM organizations
LIMIT 1
`).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.CreatedAt)
`).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.AutoUpdateWindowStart, &organization.AutoUpdateWindowEnd, &organization.AutoUpdateDays, &organization.CreatedAt)
return organization, err
}
@@ -63,6 +63,15 @@ func (r *Repository) UpdateOrganizationTheme(ctx context.Context, theme, themeMo
return err
}
func (r *Repository) UpdateOrganizationAutoUpdateWindow(ctx context.Context, start, end, days string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE organizations
SET auto_update_window_start = ?, auto_update_window_end = ?, auto_update_days = ?
WHERE id = (SELECT id FROM organizations LIMIT 1)
`, start, end, days)
return err
}
func (r *Repository) CountUsers(ctx context.Context) (int, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
@@ -71,9 +80,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 +94,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 +111,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 +130,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, `
@@ -158,7 +182,7 @@ func (r *Repository) EnsurePrimaryAdmin(ctx context.Context) error {
func (r *Repository) ListGroups(ctx context.Context, orgID int64) ([]models.VMGroup, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, organization_id, name, description, color_token, created_at
SELECT id, organization_id, name, description, color_token, icon, created_at
FROM vm_groups
WHERE organization_id = ?
ORDER BY name ASC
@@ -171,7 +195,7 @@ func (r *Repository) ListGroups(ctx context.Context, orgID int64) ([]models.VMGr
var groups []models.VMGroup
for rows.Next() {
var group models.VMGroup
if err := rows.Scan(&group.ID, &group.OrganizationID, &group.Name, &group.Description, &group.ColorToken, &group.CreatedAt); err != nil {
if err := rows.Scan(&group.ID, &group.OrganizationID, &group.Name, &group.Description, &group.ColorToken, &group.Icon, &group.CreatedAt); err != nil {
return nil, err
}
groups = append(groups, group)
@@ -182,9 +206,9 @@ func (r *Repository) ListGroups(ctx context.Context, orgID int64) ([]models.VMGr
func (r *Repository) CreateGroup(ctx context.Context, group *models.VMGroup) error {
result, err := r.db.ExecContext(ctx, `
INSERT INTO vm_groups (organization_id, name, description, color_token)
VALUES (?, ?, ?, ?)
`, group.OrganizationID, group.Name, group.Description, group.ColorToken)
INSERT INTO vm_groups (organization_id, name, description, color_token, icon)
VALUES (?, ?, ?, ?, ?)
`, group.OrganizationID, group.Name, group.Description, group.ColorToken, group.Icon)
if err != nil {
return err
}
@@ -193,6 +217,15 @@ func (r *Repository) CreateGroup(ctx context.Context, group *models.VMGroup) err
return nil
}
func (r *Repository) UpdateGroup(ctx context.Context, group *models.VMGroup) error {
_, err := r.db.ExecContext(ctx, `
UPDATE vm_groups
SET name = ?, description = ?, color_token = ?, icon = ?
WHERE id = ? AND organization_id = ?
`, group.Name, group.Description, group.ColorToken, group.Icon, group.ID, group.OrganizationID)
return err
}
func (r *Repository) ListTags(ctx context.Context, orgID int64) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT DISTINCT tag

View File

@@ -16,14 +16,22 @@
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#addJobModal">
<i class="ti ti-plus me-1"></i>Add Job
</button>
{{else if eq .CurrentPath "/updates"}}
<form method="post" action="/updates/scan">
<button class="btn btn-primary" type="submit">
<i class="ti ti-refresh me-1"></i>Scan All
</button>
</form>
{{else if eq .CurrentPath "/uptime"}}
<form method="post" action="/uptime/run">
<button class="btn btn-primary" type="submit">
<i class="ti ti-activity-heartbeat me-1"></i>Run Checks
</button>
</form>
{{else if eq .CurrentPath "/groups"}}
<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>
@@ -39,30 +47,38 @@
{{define "shell"}}
<div class="app-shell d-flex min-vh-100">
<aside class="app-sidebar border-end">
<div class="p-3 p-lg-4 d-flex flex-column h-100">
<a href="/dashboard" class="d-flex align-items-center justify-content-center gap-3 text-decoration-none mb-4 sidebar-brand">
<div class="app-sidebar-inner p-3 d-flex flex-column h-100">
<a href="/dashboard" class="d-flex align-items-center gap-3 text-decoration-none mb-4 sidebar-brand">
<img src="/static/img/maintainarr_logo.png" alt="Maintainarr" class="sidebar-logo">
<div class="text-center">
<div class="fw-bold fs-5 text-body-emphasis">Maintainarr</div>
<div class="min-w-0">
<div class="sidebar-brand-title text-body-emphasis">Maintainarr</div>
</div>
</a>
<nav class="nav flex-column gap-2 app-sidebar-nav">
<a href="/dashboard" class="btn text-start {{if eq .CurrentPath "/dashboard"}}btn-primary{{else}}btn-outline-secondary{{end}}"><i class="ti ti-layout-grid me-2"></i>Dashboard</a>
<a href="/groups" class="btn text-start {{if eq .CurrentPath "/groups"}}btn-primary{{else}}btn-outline-secondary{{end}}"><i class="ti ti-stack-2 me-2"></i>Groups</a>
<a href="/automations" class="btn text-start {{if eq .CurrentPath "/automations"}}btn-primary{{else}}btn-outline-secondary{{end}}"><i class="ti ti-clock-cog me-2"></i>Jobs</a>
<a href="/settings" class="btn text-start {{if eq .CurrentPath "/settings"}}btn-primary{{else}}btn-outline-secondary{{end}}"><i class="ti ti-settings-2 me-2"></i>Settings</a>
<div class="sidebar-section-label">Navigation</div>
<nav class="nav flex-column gap-1 app-sidebar-nav">
<a href="/dashboard" class="app-nav-link {{if eq .CurrentPath "/dashboard"}}is-active{{end}}"><i class="ti ti-layout-grid"></i><span>Dashboard</span></a>
<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="/updates" class="app-nav-link {{if eq .CurrentPath "/updates"}}is-active{{end}}"><i class="ti ti-package-import"></i><span>Updates</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-history"></i><span>Logs</span></a>
</nav>
<div class="card mt-auto border-0 sidebar-status">
<div class="card mt-auto border-0 sidebar-status shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between gap-3">
<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>
@@ -74,7 +90,19 @@
<header class="app-header border-bottom">
<div class="container-fluid px-3 px-lg-4">
<div class="d-flex align-items-center justify-content-between gap-3 py-3">
<div class="fw-semibold text-body-emphasis">{{.Title}}</div>
<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 "/updates"}}<i class="ti ti-package-import"></i>{{end}}
{{if eq .CurrentPath "/uptime"}}<i class="ti ti-activity-heartbeat"></i>{{end}}
{{if eq .CurrentPath "/settings"}}<i class="ti ti-history"></i>{{end}}
{{if eq .CurrentPath "/settings/user"}}<i class="ti ti-user-cog"></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" .}}
</div>
@@ -92,8 +120,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}}
@@ -106,7 +134,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">
@@ -114,13 +142,33 @@
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" required>
</div>
<div class="mb-3">
<label class="form-label">Icon</label>
<input type="hidden" name="icon" id="createGroupIconValue" value="ti ti-stack-2">
<button
class="group-icon-field"
type="button"
data-bs-toggle="modal"
data-bs-target="#groupIconPickerModal"
data-icon-input="createGroupIconValue"
data-icon-preview="createGroupIconPreview"
data-icon-value="ti ti-stack-2"
>
<span class="group-icon-field-preview" id="createGroupIconPreview"><i class="ti ti-stack-2"></i></span>
<span class="group-icon-field-text">
<span class="group-icon-field-title">Choose icon</span>
<span class="group-icon-field-subtitle">Search 200+ icons</span>
</span>
<i class="ti ti-chevron-right group-icon-field-arrow"></i>
</button>
</div>
<div>
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="3"></textarea>
</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>
@@ -128,18 +176,94 @@
</div>
</div>
<div class="modal fade" id="editGroupModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<form method="post" action="" data-edit-group-form>
<div class="modal-header">
<h2 class="modal-title fs-5"><i class="ti ti-pencil me-2"></i>Edit Group</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" required data-edit-group-name>
</div>
<div class="mb-3">
<label class="form-label">Icon</label>
<input type="hidden" name="icon" id="editGroupIconValue" value="ti ti-stack-2">
<button
class="group-icon-field"
type="button"
data-bs-toggle="modal"
data-bs-target="#groupIconPickerModal"
data-icon-input="editGroupIconValue"
data-icon-preview="editGroupIconPreview"
data-icon-value="ti ti-stack-2"
data-edit-group-icon-trigger
>
<span class="group-icon-field-preview" id="editGroupIconPreview"><i class="ti ti-stack-2"></i></span>
<span class="group-icon-field-text">
<span class="group-icon-field-title">Choose icon</span>
<span class="group-icon-field-subtitle">Search 200+ icons</span>
</span>
<i class="ti ti-chevron-right group-icon-field-arrow"></i>
</button>
</div>
<div>
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="3" data-edit-group-description></textarea>
</div>
</div>
<div class="modal-footer">
<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-device-floppy me-1"></i>Save</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="groupIconPickerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<div class="min-w-0">
<h2 class="modal-title fs-5"><i class="ti ti-icons me-2"></i>Choose Group Icon</h2>
<div class="small text-body-secondary">Search and pick from the icon library.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<div class="group-icon-search mb-3">
<i class="ti ti-search group-icon-search-icon"></i>
<input type="search" class="form-control" placeholder="Search icons" data-group-icon-search>
</div>
<div class="group-icon-picker-grid" data-group-icon-grid>
{{range groupIcons}}
<button class="group-icon-option" type="button" data-icon-option="{{.}}" title="{{.}}" aria-label="{{.}}">
<i class="{{.}}"></i>
<span>{{.}}</span>
</button>
{{end}}
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="addVmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<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>
@@ -167,7 +291,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>
@@ -176,12 +300,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>
@@ -197,7 +321,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>
@@ -215,7 +339,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal"><i class="ti ti-x me-1"></i>Cancel</button>
<button type="submit" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Create VM</button>
</div>
</form>

View File

@@ -1,15 +1,14 @@
{{define "shell"}}
<main class="min-vh-100 d-flex align-items-center auth-shell py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
<div class="card border-0 shadow-lg auth-card">
<div class="auth-form-wrap p-4 p-lg-5">
<main class="min-vh-100 d-flex align-items-center justify-content-center auth-shell py-5">
<div class="container-tight w-100 px-3 py-4">
<div class="text-center mb-4">
<img src="/static/img/maintainarr_logo.png" alt="Maintainarr" class="auth-logo">
</div>
<div class="card border-0 shadow-lg auth-card auth-card-tabler">
<div class="card-body p-4 p-lg-5">
{{template "content" .}}
</div>
</div>
</div>
</div>
</div>
</main>
{{end}}

View File

@@ -5,18 +5,22 @@
<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">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/@fortawesome/[email protected]/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.44.0/dist/tabler-icons.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/font-logos@1/assets/font-logos.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="theme-{{.ThemeClass}} bg-body-tertiary" data-bs-theme="{{.ThemeMode}}">
{{template "shell" .}}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.5.0/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="/static/js/app.js"></script>

View File

@@ -2,8 +2,8 @@
{{$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>
@@ -13,7 +13,7 @@
<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>
<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 text-bg-primary">{{len $data.Jobs}} jobs</span>
@@ -24,7 +24,7 @@
<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>{{.Name}}</strong>
<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>
@@ -51,6 +51,12 @@
</ol>
</div>
</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>
{{end}}
</div>
</div>
@@ -64,7 +70,7 @@
<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>
<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>
@@ -73,7 +79,7 @@
<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>{{if .JobName}}{{.JobName}}{{else}}{{.Action}}{{end}}</strong>
<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">
@@ -84,7 +90,9 @@
<pre class="run-pre mb-0">{{.Output}}</pre>
</div>
{{else}}
<div class="text-body-secondary">No job runs yet.</div>
<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>
@@ -97,7 +105,7 @@
<div class="modal-content border-0 shadow-lg">
<form method="post" action="/automations" id="job-form">
<div class="modal-header">
<h2 class="modal-title fs-5">Create Job</h2>
<h2 class="modal-title fs-5"><i class="ti ti-clock-cog me-2"></i>Create Job</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -189,7 +197,7 @@
<div class="col-12 col-lg-4">
<div class="d-flex align-items-center justify-content-between mb-2">
<label class="form-label mb-0">Commands</label>
<button class="btn btn-sm btn-outline-secondary" type="button" id="add-command-step">Add step</button>
<button class="btn btn-sm btn-outline-secondary" type="button" id="add-command-step"><i class="ti ti-plus me-1"></i>Add step</button>
</div>
<div id="command-steps" class="vstack gap-2">
<div class="input-group command-step">
@@ -201,7 +209,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal"><i class="ti ti-x me-1"></i>Cancel</button>
<button type="submit" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Create Job</button>
</div>
</form>

View File

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

View File

@@ -4,11 +4,11 @@
<div class="card-body p-0">
<div class="node-tile">
<div class="node-tile-icon">
<div class="chip-icon">
<div class="chip-icon chip-icon-plain node-brand-icon">
{{if nodeIconPending .Distro .PackageManager}}
<i class="ti ti-loader-2 node-loading-icon"></i>
{{else}}
{{icon (nodeIconName .Distro .PackageManager)}}
<i class="{{nodeIconClass .Distro .PackageManager}} distro-icon"></i>
{{end}}
</div>
</div>
@@ -68,7 +68,10 @@
{{range $data.Groups}}
<section class="mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h2 class="h6 text-uppercase text-body-secondary mb-0">{{.Name}}</h2>
<h2 class="h6 text-uppercase text-body-secondary mb-0 d-flex align-items-center gap-2">
<i class="{{.Icon}}"></i>
<span>{{.Name}}</span>
</h2>
<span class="small text-body-secondary">{{len .Nodes}}</span>
</div>
<div class="row g-3">

View File

@@ -1,26 +1,49 @@
{{define "content"}}
{{$data := .Content}}
{{if $data.Nodes}}
<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>
{{if $data.Groups}}
<section class="row g-4">
{{range $data.Groups}}
<div class="col-12 col-xl-6 col-xxl-4">
<article class="card border-0 shadow-sm h-100">
<div class="card-body p-4">
<div class="d-flex align-items-start justify-content-between gap-3 mb-4">
<div class="d-flex align-items-start gap-3 min-w-0">
<div class="brand-mark">
<i class="{{.Group.Icon}}"></i>
</div>
<div class="min-w-0">
<h2 class="h4 mb-1 text-body-emphasis">{{.Group.Name}}</h2>
{{if .Group.Description}}<div class="small text-body-secondary">{{.Group.Description}}</div>{{end}}
</div>
</div>
{{if and $.User (ne $.User.Role "viewer")}}
<button
class="btn btn-outline-secondary btn-sm"
type="button"
data-bs-toggle="modal"
data-bs-target="#editGroupModal"
data-group-id="{{.Group.ID}}"
data-group-name="{{.Group.Name}}"
data-group-description="{{.Group.Description}}"
data-group-icon="{{.Group.Icon}}"
>
<i class="ti ti-pencil"></i>
</button>
{{end}}
</tbody>
</table>
</div>
<div class="d-flex align-items-center justify-content-between">
<span class="text-body-secondary small text-uppercase fw-semibold">VMs</span>
<span class="fs-4 fw-bold text-body-emphasis">{{.VMCount}}</span>
</div>
</div>
</article>
</div>
{{end}}
</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-stack-2 fs-1 d-block mb-3"></i>No groups yet.
</div>
</section>
{{end}}

View File

@@ -1,18 +1,20 @@
{{define "content"}}
<div class="mb-4">
<h2 class="fw-bold mb-0">Sign In</h2>
<div class="text-center mb-4">
<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>
<input type="email" class="form-control form-control-lg" name="email" required>
<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>
<input type="password" class="form-control form-control-lg" name="password" required>
<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"><i class="ti ti-login-2 me-2"></i>Continue</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>
<p class="mt-4 text-body-secondary mb-0"><a href="/register" class="link-primary link-offset-2">Create account</a></p>
<div class="text-center mt-4 small text-body-secondary">
<a href="/register" class="link-primary link-offset-2"><i class="ti ti-user-plus me-1"></i>Create account</a>
</div>
{{end}}

View File

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

View File

@@ -2,11 +2,23 @@
{{$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">
{{if and $.User (ne $.User.Role "viewer")}}
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#editNodeModal">
<i class="ti ti-pencil me-2"></i>Edit
</button>
{{end}}
<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>
@@ -15,13 +27,100 @@
</div>
</section>
{{if and $.User (ne $.User.Role "viewer")}}
<div class="modal fade" id="editNodeModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content border-0 shadow-lg">
<form method="post" action="/nodes/{{$data.Node.ID}}">
<div class="modal-header">
<h2 class="modal-title fs-5"><i class="ti ti-pencil me-2"></i>Edit 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"><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>
<input type="text" class="form-control" name="ip_address" value="{{$data.Node.IPAddress}}" required>
</div>
<div class="col-12 col-lg-6">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" value="{{$data.Node.Name}}">
</div>
<div class="col-12 col-lg-6">
<label class="form-label">Group</label>
<select class="form-select" name="group_id">
<option value="">None</option>
{{range $.AvailableGroups}}<option value="{{.ID}}" {{if eq .ID $data.SelectedGroupID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-12 col-lg-6">
<label class="form-label">Tag</label>
<input type="text" class="form-control" name="tag" list="edit-vm-tag-options" value="{{$data.Node.Tag}}" placeholder="prod, edge, lab">
<datalist id="edit-vm-tag-options">
{{range $.AvailableTags}}<option value="{{.}}"></option>{{end}}
</datalist>
</div>
</div>
</section>
<section class="add-vm-panel">
<div class="add-vm-panel-title"><i class="ti ti-key me-2"></i>Access</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<label class="form-label">Username</label>
<input type="text" class="form-control" name="ssh_username" value="{{$data.Node.SSHUsername}}" required>
</div>
<div class="col-12 col-lg-6">
<label class="form-label">Password</label>
<input type="password" class="form-control" name="ssh_password" placeholder="Leave blank to keep current password">
</div>
</div>
</section>
<section class="add-vm-panel add-vm-panel-wide">
<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>
<textarea class="form-control" name="notes" rows="4">{{$data.Node.Notes}}</textarea>
</div>
<div class="col-12 col-lg-4">
<label class="form-label">Behavior</label>
<div class="vstack gap-2">
<label class="option-check">
<input class="form-check-input" type="checkbox" name="updates_scan_enabled" {{if $data.Node.UpdatesScanEnabled}}checked{{end}}>
<span>Scan for updates</span>
</label>
<label class="option-check">
<input class="form-check-input" type="checkbox" name="auto_updates_enabled" {{if $data.Node.AutoUpdatesEnabled}}checked{{end}}>
<span>Enable auto updates</span>
</label>
</div>
</div>
</div>
</section>
</div>
</div>
<div class="modal-footer">
<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-device-floppy me-1"></i>Save</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
<section class="row g-3 mb-4 node-live" data-node-id="{{$data.Node.ID}}">
<div class="col-12 col-md-6 col-xxl-3">
<div class="card border-0 shadow-sm stat-card kpi-card h-100" data-kpi="cpu">
<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="small text-body-secondary mb-2">CPU</div>
<div class="display-6 fw-bold" data-kpi-value="cpu">{{printf "%.1f" $data.Node.CPUUsage}}%</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>
</div>
</div>
@@ -60,8 +159,8 @@
<div class="card-body p-4">
<div class="system-summary vstack gap-3">
<div class="system-summary-item">
<div class="chip-icon system-summary-icon">
<i class="{{nodeIconClass $data.Node.Distro $data.Node.PackageManager}}"></i>
<div class="chip-icon chip-icon-plain system-summary-icon system-summary-icon-plain">
<i class="{{nodeIconClass $data.Node.Distro $data.Node.PackageManager}} distro-icon distro-icon-lg"></i>
</div>
<div class="min-w-0">
<div class="system-summary-label">Distribution</div>
@@ -69,7 +168,7 @@
</div>
</div>
<div class="system-summary-item">
<div class="chip-icon system-summary-icon">
<div class="chip-icon chip-icon-plain system-summary-icon system-summary-icon-plain">
<i class="{{packageManagerIconClass $data.Node.PackageManager}}"></i>
</div>
<div class="min-w-0">
@@ -78,22 +177,34 @@
</div>
</div>
<div class="system-summary-stats">
<div class="system-summary-stat">
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.IPAddress}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy IP">
<span class="text-body-secondary">IP</span>
<span class="text-end">{{$data.Node.IPAddress}}</span>
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.MACAddress}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy MAC">
<span class="text-body-secondary">MAC</span>
<span class="text-end">{{if $data.Node.MACAddress}}{{$data.Node.MACAddress}}{{else}}-{{end}}</span>
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.SSHUsername}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy username">
<span class="text-body-secondary">Username</span>
<span class="text-end">{{if $data.Node.SSHUsername}}{{$data.Node.SSHUsername}}{{else}}-{{end}}</span>
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.Architecture}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy architecture">
<span class="text-body-secondary">Architecture</span>
<span class="text-end">{{$data.Node.Architecture}}</span>
</div>
<div class="system-summary-stat">
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{$data.Node.KernelVersion}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy kernel">
<span class="text-body-secondary">Kernel</span>
<span class="text-end">{{$data.Node.KernelVersion}}</span>
</div>
<div class="system-summary-stat">
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{if $data.Node.MemoryTotalMB}}{{$data.Node.MemoryTotalMB}} MB{{end}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy memory">
<span class="text-body-secondary">Memory</span>
<span class="text-end">{{if $data.Node.MemoryTotalMB}}{{$data.Node.MemoryTotalMB}} MB{{else}}-{{end}}</span>
</div>
<div class="system-summary-stat">
</button>
<button class="system-summary-stat system-summary-copy" type="button" data-copy-value="{{if $data.Node.DiskTotalGB}}{{$data.Node.DiskTotalGB}} GB{{end}}" data-bs-toggle-tooltip="tooltip" data-bs-title="Copy disk">
<span class="text-body-secondary">Disk</span>
<span class="text-end">{{if $data.Node.DiskTotalGB}}{{$data.Node.DiskTotalGB}} GB{{else}}-{{end}}</span>
</div>
</button>
</div>
</div>
</div>

View File

@@ -1,22 +1,24 @@
{{define "content"}}
<div class="mb-4">
<h2 class="fw-bold mb-0">Create Account</h2>
<div class="text-center mb-4">
<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>
<input type="text" class="form-control form-control-lg" name="name" required>
<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>
<input type="email" class="form-control form-control-lg" name="email" required>
<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>
<input type="password" class="form-control form-control-lg" name="password" required>
<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"><i class="ti ti-user-plus me-2"></i>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>
<p class="mt-4 text-body-secondary mb-0">Already registered? <a href="/login" class="link-primary link-offset-2">Sign in</a></p>
<div class="text-center mt-4 small text-body-secondary">
<a href="/login" class="link-primary link-offset-2"><i class="ti ti-arrow-left me-1"></i>Back to sign in</a>
</div>
{{end}}

View File

@@ -1,93 +1,95 @@
{{define "content"}}
{{$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">Five defaults. Colored themes work in dark or light.</p>
<section class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-darkish align-middle mb-0 logs-table">
<thead>
<tr>
<th>Target</th>
<th>Time</th>
<th>Status</th>
<th>Duration</th>
<th>Command</th>
</tr>
</thead>
<tbody>
{{if $data.Logs}}
{{range $index, $log := $data.Logs}}
<tr>
<td class="text-nowrap fw-semibold">{{$log.Target}}</td>
<td class="text-nowrap">{{$log.StartedAt.Format "2006-01-02 15:04:05"}}</td>
<td>
<span class="badge {{if eq $log.Status "completed"}}text-bg-success{{else}}text-bg-danger{{end}}">{{$log.Status}}</span>
</td>
<td class="text-nowrap text-body-secondary">{{if $log.Duration}}{{$log.Duration}}{{else}}-{{end}}</td>
<td class="logs-command-cell">
<button
type="button"
class="btn btn-link logs-command-button"
data-bs-toggle="modal"
data-bs-target="#commandLogModal"
data-log-target="{{$log.Target}}"
data-log-time="{{$log.StartedAt.Format "2006-01-02 15:04:05"}}"
data-log-status="{{$log.Status}}"
data-log-duration="{{$log.Duration}}"
data-log-command="{{$log.CommandText}}"
data-log-output="{{$log.Output}}"
>
<code class="logs-command-preview">{{$log.CommandText}}</code>
</button>
</td>
</tr>
{{end}}
{{else}}
<tr>
<td colspan="5" class="text-body-secondary text-center py-5">No command history yet.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</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">
<div class="mb-4">
<label class="form-label d-block">Mode</label>
<div class="d-flex gap-2 flex-wrap">
<input type="radio" class="btn-check" name="mode" id="mode-dark" value="dark" {{if eq $data.CurrentMode "dark"}}checked{{end}}>
<label class="btn btn-outline-secondary" for="mode-dark">Dark</label>
<input type="radio" class="btn-check" name="mode" id="mode-light" value="light" {{if eq $data.CurrentMode "light"}}checked{{end}}>
<label class="btn btn-outline-secondary" for="mode-light">Light</label>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-6 col-xxl-4">
<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 col-xxl-4">
<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 class="col-12 col-md-6 col-xxl-4">
<input type="radio" class="btn-check" name="theme" id="theme-green" value="green" {{if eq $data.CurrentTheme "green"}}checked{{end}}>
<label class="theme-card d-block h-100" for="theme-green">
<span class="theme-swatch swatch-green"></span>
<strong>Green</strong>
<small>Computed green accents</small>
</label>
</div>
<div class="col-12 col-md-6 col-xxl-4">
<input type="radio" class="btn-check" name="theme" id="theme-red" value="red" {{if eq $data.CurrentTheme "red"}}checked{{end}}>
<label class="theme-card d-block h-100" for="theme-red">
<span class="theme-swatch swatch-red"></span>
<strong>Red</strong>
<small>Computed red accents</small>
</label>
</div>
<div class="col-12 col-md-6 col-xxl-4">
<input type="radio" class="btn-check" name="theme" id="theme-blue" value="blue" {{if eq $data.CurrentTheme "blue"}}checked{{end}}>
<label class="theme-card d-block h-100" for="theme-blue">
<span class="theme-swatch swatch-blue"></span>
<strong>Blue</strong>
<small>Computed blue accents</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">
<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>
<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>
</div>
</article>
</div>
{{if gt $data.TotalPages 1}}
<section class="d-flex align-items-center justify-content-between gap-3 mt-3">
<div class="small text-body-secondary">Page {{$data.Page}} of {{$data.TotalPages}}</div>
<nav aria-label="Logs pagination">
<ul class="pagination pagination-sm mb-0">
<li class="page-item {{if not $data.HasPrev}}disabled{{end}}">
<a class="page-link" href="{{$data.CurrentPath}}?page={{$data.PrevPage}}" {{if not $data.HasPrev}}tabindex="-1" aria-disabled="true"{{end}}>Previous</a>
</li>
<li class="page-item disabled"><span class="page-link">{{$data.Page}}</span></li>
<li class="page-item {{if not $data.HasNext}}disabled{{end}}">
<a class="page-link" href="{{$data.CurrentPath}}?page={{$data.NextPage}}" {{if not $data.HasNext}}tabindex="-1" aria-disabled="true"{{end}}>Next</a>
</li>
</ul>
</nav>
</section>
{{end}}
<div class="modal fade" id="commandLogModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<div class="min-w-0">
<h2 class="modal-title fs-5 mb-1">Command Log</h2>
<div class="small text-body-secondary" data-command-log-meta></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="logs-modal-section">
<div class="logs-modal-label">Command</div>
<pre class="logs-modal-pre" data-command-log-command></pre>
</div>
<div class="logs-modal-section">
<div class="logs-modal-label">Output</div>
<pre class="logs-modal-pre" data-command-log-output></pre>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -1,8 +1,8 @@
{{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">
@@ -16,11 +16,11 @@
<div class="col-md-8">
<form method="post">
<div class="mb-3">
<label class="form-label">Manual secret</label>
<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">Authenticator code</label>
<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>

View File

@@ -0,0 +1,264 @@
{{define "updatesNodeRows"}}
{{range .}}
<tr>
<td>
<div class="fw-semibold text-body-emphasis">{{.Name}}</div>
<div class="small text-body-secondary">{{packageManagerLabel .PackageManager}}{{if .UpdatesLastError}} · {{.UpdatesLastError}}{{end}}</div>
</td>
<td class="text-nowrap">
<div class="d-flex align-items-center gap-2">
<span class="badge {{if gt .UpdatesAvailable 0}}text-bg-warning{{else}}text-bg-secondary{{end}}">{{.UpdatesAvailable}}</span>
{{if and (gt .UpdatesAvailable 0) .UpdatesDetails}}
<button
class="btn btn-link btn-sm updates-details-trigger"
type="button"
data-bs-toggle="modal"
data-bs-target="#updatePackagesModal"
data-node-name="{{.Name}}"
data-package-count="{{.UpdatesAvailable}}"
data-packages="{{.UpdatesDetails}}"
>
View packages
</button>
{{end}}
</div>
</td>
<td class="text-nowrap">{{if .UpdatesLastChecked}}{{.UpdatesLastChecked.Format "2006-01-02 15:04:05"}}{{else}}Never{{end}}</td>
<td>
<form id="node-policy-{{.ID}}" method="post" action="/updates/nodes/{{.ID}}/policy"></form>
<input class="form-check-input mt-0" type="checkbox" name="updates_scan_enabled" form="node-policy-{{.ID}}" {{if .UpdatesScanEnabled}}checked{{end}}>
</td>
<td>
<input class="form-check-input mt-0" type="checkbox" name="auto_updates_enabled" form="node-policy-{{.ID}}" {{if .AutoUpdatesEnabled}}checked{{end}}>
</td>
<td>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-outline-secondary btn-sm" type="submit" form="node-policy-{{.ID}}"><i class="ti ti-device-floppy"></i></button>
<form method="post" action="/updates/nodes/{{.ID}}/scan">
<button class="btn btn-outline-secondary btn-sm" type="submit"><i class="ti ti-refresh"></i></button>
</form>
<form method="post" action="/updates/nodes/{{.ID}}/apply">
<button class="btn btn-primary btn-sm" type="submit"><i class="ti ti-package-import"></i></button>
</form>
</div>
</td>
</tr>
{{end}}
{{end}}
{{define "content"}}
{{$data := .Content}}
<section class="row g-3 mb-4">
<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"><i class="ti ti-package me-2"></i>Pending packages</div>
<div class="uptime-summary-value">{{$data.TotalUpdates}}</div>
</div>
</article>
</div>
<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"><i class="ti ti-server-2 me-2"></i>Nodes with updates</div>
<div class="uptime-summary-value">{{$data.NodesWithUpdates}}</div>
</div>
</article>
</div>
<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"><i class="ti ti-radar-2 me-2"></i>Scanned nodes</div>
<div class="uptime-summary-value">{{$data.ScannedNodes}}</div>
</div>
</article>
</div>
<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"><i class="ti ti-bolt me-2"></i>Auto update</div>
<div class="uptime-summary-value">{{$data.AutoUpdateNodes}}</div>
</div>
</article>
</div>
</section>
<section class="card border-0 shadow-sm mb-4">
<div class="card-body p-4 d-flex flex-column flex-xl-row align-items-xl-center justify-content-between gap-4">
<div class="min-w-0">
<div class="uptime-summary-label mb-2"><i class="ti ti-clock-cog me-2"></i>Global Auto Update Window</div>
<div class="h4 mb-1">{{updateWindowSummary $data.GlobalWindowStart $data.GlobalWindowEnd $data.GlobalUpdateDays}}</div>
<div class="text-body-secondary small">Automatic package upgrades respect this org-wide schedule so updates stay out of peak hours.</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#globalUpdateWindowModal">
<i class="ti ti-settings me-2"></i>Edit Window
</button>
<form method="post" action="/updates/scan">
<button class="btn btn-outline-primary" type="submit"><i class="ti ti-refresh me-2"></i>Scan all nodes</button>
</form>
<form method="post" action="/updates/apply">
<button class="btn btn-primary" type="submit"><i class="ti ti-package-import me-2"></i>Update all</button>
</form>
</div>
</div>
</section>
{{range $data.Groups}}
<section class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex flex-column flex-xl-row align-items-xl-center justify-content-between gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{.Name}}</h2>
<div class="text-body-secondary small">{{.UpdatesAvailable}} updates on {{.NodesWithUpdates}} nodes</div>
</div>
<div class="d-flex flex-wrap gap-2">
<form method="post" action="/updates/groups/{{.ID}}/scan">
<button class="btn btn-outline-secondary btn-sm" type="submit"><i class="ti ti-refresh me-1"></i>Scan Group</button>
</form>
<form method="post" action="/updates/groups/{{.ID}}/apply">
<button class="btn btn-primary btn-sm" type="submit"><i class="ti ti-package-import me-1"></i>Update Group</button>
</form>
</div>
</div>
<form method="post" action="/updates/groups/{{.ID}}/policy" class="updates-policy-row mb-4">
<label class="option-check">
<input class="form-check-input" type="checkbox" name="updates_scan_enabled" {{if .ScanEnabled}}checked{{end}}>
<span>Scan this group</span>
</label>
<label class="option-check">
<input class="form-check-input" type="checkbox" name="auto_updates_enabled" {{if .AutoUpdate}}checked{{end}}>
<span>Auto update this group</span>
</label>
<button class="btn btn-outline-secondary btn-sm" type="submit"><i class="ti ti-device-floppy me-1"></i>Save</button>
</form>
<div class="table-responsive">
<table class="table align-middle mb-0 uptime-table uptime-table-nodes">
<thead>
<tr>
<th>Node</th>
<th>Updates</th>
<th>Last Scan</th>
<th>Scan</th>
<th>Auto</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{template "updatesNodeRows" .Nodes}}
</tbody>
</table>
</div>
</div>
</section>
{{end}}
{{if $data.Ungrouped}}
<section class="card border-0 shadow-sm">
<div class="card-body p-4">
<h2 class="h4 mb-4">Ungrouped</h2>
<div class="table-responsive">
<table class="table align-middle mb-0 uptime-table uptime-table-nodes">
<thead>
<tr>
<th>Node</th>
<th>Updates</th>
<th>Last Scan</th>
<th>Scan</th>
<th>Auto</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{template "updatesNodeRows" $data.Ungrouped}}
</tbody>
</table>
</div>
</div>
</section>
{{end}}
<div class="modal fade" id="globalUpdateWindowModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<form method="post" action="/updates/settings/window">
<div class="modal-header">
<div class="min-w-0">
<h2 class="modal-title fs-5"><i class="ti ti-clock-cog me-2"></i>Global Auto Update Window</h2>
<div class="small text-body-secondary">Choose when automatic updates are allowed to run across the whole organization.</div>
</div>
<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="updates-window-editor">
<section class="add-vm-panel">
<div class="add-vm-panel-title"><i class="ti ti-calendar-time me-2"></i>Allowed Days</div>
<div class="updates-weekdays updates-weekdays-lg">
<label class="updates-day-chip updates-day-chip-lg"><input type="checkbox" name="auto_update_days" value="mon" {{if hasUpdateDay $data.GlobalUpdateDays "mon"}}checked{{end}}><span>Monday</span></label>
<label class="updates-day-chip updates-day-chip-lg"><input type="checkbox" name="auto_update_days" value="tue" {{if hasUpdateDay $data.GlobalUpdateDays "tue"}}checked{{end}}><span>Tuesday</span></label>
<label class="updates-day-chip updates-day-chip-lg"><input type="checkbox" name="auto_update_days" value="wed" {{if hasUpdateDay $data.GlobalUpdateDays "wed"}}checked{{end}}><span>Wednesday</span></label>
<label class="updates-day-chip updates-day-chip-lg"><input type="checkbox" name="auto_update_days" value="thu" {{if hasUpdateDay $data.GlobalUpdateDays "thu"}}checked{{end}}><span>Thursday</span></label>
<label class="updates-day-chip updates-day-chip-lg"><input type="checkbox" name="auto_update_days" value="fri" {{if hasUpdateDay $data.GlobalUpdateDays "fri"}}checked{{end}}><span>Friday</span></label>
<label class="updates-day-chip updates-day-chip-lg"><input type="checkbox" name="auto_update_days" value="sat" {{if hasUpdateDay $data.GlobalUpdateDays "sat"}}checked{{end}}><span>Saturday</span></label>
<label class="updates-day-chip updates-day-chip-lg"><input type="checkbox" name="auto_update_days" value="sun" {{if hasUpdateDay $data.GlobalUpdateDays "sun"}}checked{{end}}><span>Sunday</span></label>
</div>
</section>
<section class="add-vm-panel">
<div class="add-vm-panel-title"><i class="ti ti-clock-hour-9 me-2"></i>Allowed Time Range</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label">Start</label>
<input class="form-control" type="time" name="auto_update_window_start" value="{{$data.GlobalWindowStart}}">
</div>
<div class="col-12 col-md-6">
<label class="form-label">End</label>
<input class="form-control" type="time" name="auto_update_window_end" value="{{$data.GlobalWindowEnd}}">
</div>
</div>
<div class="small text-body-secondary mt-3">Leave start/end empty to allow automatic updates at any time on the selected days.</div>
</section>
</div>
</div>
<div class="modal-footer">
<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-device-floppy me-1"></i>Save Window</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="updatePackagesModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<div class="min-w-0">
<h2 class="modal-title fs-5"><i class="ti ti-package me-2"></i>Pending Packages</h2>
<div class="small text-body-secondary" data-update-packages-node></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div class="table-responsive">
<table class="table align-middle mb-0 updates-packages-table">
<thead>
<tr>
<th>Package</th>
<th>Current</th>
<th>Available</th>
<th>Arch</th>
</tr>
</thead>
<tbody data-update-packages-body>
<tr>
<td colspan="4" class="text-body-secondary text-center py-4">No pending packages.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,206 @@
{{define "content"}}
{{$data := .Content}}
<section class="row g-3 mb-4">
<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"><i class="ti ti-radar-2 me-2"></i>Monitors</div>
<div class="uptime-summary-value">{{$data.Summary.TotalMonitors}}</div>
</div>
</article>
</div>
<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"><i class="ti ti-circle-check me-2"></i>Up</div>
<div class="uptime-summary-value">{{$data.Summary.UpMonitors}}</div>
</div>
</article>
</div>
<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"><i class="ti ti-alert-circle me-2"></i>Down</div>
<div class="uptime-summary-value">{{$data.Summary.DownMonitors}}</div>
</div>
</article>
</div>
<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"><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>
</div>
</section>
<section class="row g-4 mb-4">
<div class="col-12 col-xxl-8">
<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">
<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="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="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>
</article>
</div>
<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"><i class="ti ti-report-analytics me-2"></i>Availability</h2>
<div class="table-responsive">
<table class="table align-middle mb-0 uptime-table uptime-table-compact">
<thead>
<tr>
<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>
{{range $data.Periods}}
<tr>
<td class="text-nowrap">{{.Label}}</td>
<td class="text-nowrap">{{.AvailabilityText}}</td>
<td class="text-nowrap">{{.DowntimeText}}</td>
<td>{{.Incidents}}</td>
<td class="text-nowrap">{{.LongestText}}</td>
<td class="text-nowrap">{{.AverageText}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</article>
</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"><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 uptime-table-compact">
<thead>
<tr>
<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>
{{if $data.Incidents}}
{{range $data.Incidents}}
<tr>
<td class="text-nowrap">{{.MonitorName}}</td>
<td class="text-nowrap">{{.StartedAt.Format "2006-01-02 15:04:05"}}</td>
<td class="text-nowrap">{{if .EndedAt}}{{.EndedAt.Format "2006-01-02 15:04:05"}}{{else}}Active{{end}}</td>
<td class="text-nowrap">{{.DurationText}}</td>
<td>{{if .ErrorMessage}}{{.ErrorMessage}}{{else}}Connection failed{{end}}</td>
</tr>
{{end}}
{{else}}
<tr>
<td colspan="5" class="text-body-secondary">No incidents recorded yet.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</article>
</section>
{{end}}

View File

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

View File

@@ -2,12 +2,15 @@ package views
import (
"embed"
"encoding/json"
"html/template"
"net/http"
"path"
"strconv"
"strings"
"time"
"maintainarr/internal/models"
)
//go:embed layouts/*.gohtml pages/*.gohtml
@@ -38,10 +41,13 @@ func NewRenderer() (*Renderer, error) {
"contains": strings.Contains,
"distroIconClass": distroIconClass,
"nodeIconClass": nodeIconClass,
"nodeIconName": nodeIconName,
"nodeIconPending": nodeIconPending,
"packageManagerIconClass": packageManagerIconClass,
"packageManagerLabel": packageManagerLabel,
"groupIcons": groupIcons,
"hasUpdateDay": hasUpdateDay,
"updateWindowSummary": updateWindowSummary,
"updatePackages": updatePackages,
"uptime": formatUptime,
"safeHTML": func(value string) template.HTML { return template.HTML(value) },
"nowYear": func() int { return time.Now().Year() },
@@ -134,11 +140,11 @@ func distroIconClass(distro string) string {
value := strings.ToLower(strings.TrimSpace(distro))
switch {
case strings.Contains(value, "ubuntu"):
return "fa-brands fa-ubuntu"
return "fl-ubuntu"
case strings.Contains(value, "debian"):
return "fa-brands fa-debian"
return "fl-debian"
case strings.Contains(value, "arch"), strings.Contains(value, "manjaro"), strings.Contains(value, "endeavouros"):
return "ti ti-brand-archlinux"
return "fl-archlinux"
default:
return "ti ti-server-2"
}
@@ -151,35 +157,14 @@ func nodeIconClass(distro, packageManager string) string {
switch strings.ToLower(strings.TrimSpace(packageManager)) {
case "apt":
return "fa-brands fa-debian"
return "fl-debian"
case "pacman":
return "ti ti-brand-archlinux"
return "fl-archlinux"
default:
return "ti ti-server-2"
}
}
func nodeIconName(distro, packageManager string) string {
value := strings.ToLower(strings.TrimSpace(distro))
switch {
case strings.Contains(value, "ubuntu"):
return "ubuntu"
case strings.Contains(value, "debian"):
return "debian"
case strings.Contains(value, "arch"), strings.Contains(value, "manjaro"), strings.Contains(value, "endeavouros"):
return "arch"
}
switch strings.ToLower(strings.TrimSpace(packageManager)) {
case "apt":
return "debian"
case "pacman":
return "arch"
default:
return "server"
}
}
func nodeIconPending(distro, packageManager string) bool {
distroValue := strings.ToLower(strings.TrimSpace(distro))
packageValue := strings.ToLower(strings.TrimSpace(packageManager))
@@ -189,7 +174,7 @@ func nodeIconPending(distro, packageManager string) bool {
func packageManagerIconClass(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "apt":
return "fa-brands fa-debian"
return "ti ti-brand-debian"
case "pacman":
return "ti ti-brand-archlinux"
case "dnf", "yum", "zypper", "apk", "nix", "emerge":
@@ -216,6 +201,83 @@ func packageManagerLabel(value string) string {
}
}
func updatePackages(value string) []models.NodeUpdatePackage {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
var packages []models.NodeUpdatePackage
if err := json.Unmarshal([]byte(trimmed), &packages); err != nil {
return nil
}
return packages
}
func groupIcons() []string {
return []string{
"ti ti-stack-2", "ti ti-server", "ti ti-server-2", "ti ti-server-bolt", "ti ti-server-cog", "ti ti-server-off",
"ti ti-server-spark", "ti ti-cloud", "ti ti-cloud-bolt", "ti ti-cloud-cog", "ti ti-cloud-code", "ti ti-cloud-data-connection",
"ti ti-cloud-computing", "ti ti-cloud-check", "ti ti-cloud-up", "ti ti-cloud-down", "ti ti-cloud-lock", "ti ti-cloud-off",
"ti ti-cloud-x", "ti ti-database", "ti ti-database-export", "ti ti-database-import", "ti ti-database-cog", "ti ti-database-off",
"ti ti-database-search", "ti ti-database-smile", "ti ti-table", "ti ti-table-options", "ti ti-table-export", "ti ti-table-import",
"ti ti-network", "ti ti-router", "ti ti-route", "ti ti-world", "ti ti-world-www", "ti ti-access-point",
"ti ti-wifi", "ti ti-wifi-off", "ti ti-antenna-bars-5", "ti ti-broadcast", "ti ti-radar-2", "ti ti-satellite",
"ti ti-satellite-off", "ti ti-direction-sign", "ti ti-git-branch", "ti ti-git-commit", "ti ti-git-merge", "ti ti-git-pull-request",
"ti ti-brand-docker", "ti ti-brand-kubernetes", "ti ti-brand-github", "ti ti-brand-gitlab", "ti ti-brand-openai", "ti ti-brand-aws",
"ti ti-brand-google", "ti ti-brand-windows", "ti ti-brand-debian", "ti ti-brand-ubuntu", "ti ti-brand-archlinux", "ti ti-brand-python",
"ti ti-brand-javascript", "ti ti-brand-typescript", "ti ti-brand-go", "ti ti-brand-c-sharp", "ti ti-brand-vscode", "ti ti-brand-powershell",
"ti ti-device-desktop", "ti ti-device-imac", "ti ti-device-laptop", "ti ti-device-tablet", "ti ti-device-mobile", "ti ti-devices",
"ti ti-devices-2", "ti ti-screen-share", "ti ti-screen-share-off", "ti ti-app-window", "ti ti-browser", "ti ti-browser-check",
"ti ti-browser-cog", "ti ti-browser-off", "ti ti-layout-dashboard", "ti ti-layout-grid", "ti ti-layout-list", "ti ti-layout-kanban",
"ti ti-layout-navbar", "ti ti-layout-sidebar", "ti ti-menu-2", "ti ti-panel-left", "ti ti-panel-right", "ti ti-panorama-horizontal",
"ti ti-package", "ti ti-package-import", "ti ti-package-export", "ti ti-package-off", "ti ti-box", "ti ti-box-multiple",
"ti ti-box-model", "ti ti-archive", "ti ti-archive-off", "ti ti-folders", "ti ti-folder", "ti ti-folder-open",
"ti ti-folder-cog", "ti ti-folder-bolt", "ti ti-file-stack", "ti ti-file-database", "ti ti-file-settings", "ti ti-file-code",
"ti ti-file-zip", "ti ti-files", "ti ti-scan", "ti ti-search", "ti ti-search-off", "ti ti-filter",
"ti ti-filter-cog", "ti ti-filter-search", "ti ti-zoom-scan", "ti ti-map-search", "ti ti-binary", "ti ti-braces",
"ti ti-brackets", "ti ti-code", "ti ti-code-circle", "ti ti-code-dots", "ti ti-code-plus", "ti ti-code-minus",
"ti ti-api", "ti ti-terminal", "ti ti-terminal-2", "ti ti-command", "ti ti-prompt", "ti ti-script",
"ti ti-bug", "ti ti-bug-off", "ti ti-test-pipe", "ti ti-progress-check", "ti ti-checks", "ti ti-checkup-list",
"ti ti-chart-bar", "ti ti-chart-donut", "ti ti-chart-line", "ti ti-chart-area", "ti ti-chart-histogram", "ti ti-chart-infographic",
"ti ti-activity", "ti ti-activity-heartbeat", "ti ti-pulse", "ti ti-timeline", "ti ti-gauge", "ti ti-meter-cube",
"ti ti-wave-sine", "ti ti-wave-square", "ti ti-wave-triangle", "ti ti-heart-rate-monitor", "ti ti-clock", "ti ti-clock-cog",
"ti ti-calendar-time", "ti ti-history", "ti ti-refresh", "ti ti-reload", "ti ti-repeat", "ti ti-rotate-clockwise-2",
"ti ti-cpu", "ti ti-cpu-2", "ti ti-microchip", "ti ti-memory", "ti ti-ram", "ti ti-gpu-card",
"ti ti-disc", "ti ti-disc-off", "ti ti-hard-drive", "ti ti-device-sd-card", "ti ti-sd-card", "ti ti-device-usb",
"ti ti-usb", "ti ti-plug", "ti ti-plug-connected", "ti ti-power", "ti ti-bolt", "ti ti-bolt-off",
"ti ti-battery", "ti ti-battery-vertical", "ti ti-sun-electricity", "ti ti-circle-dashed", "ti ti-circle-check", "ti ti-circle-x",
"ti ti-circle-plus", "ti ti-circle-minus", "ti ti-circle-key", "ti ti-key", "ti ti-key-off", "ti ti-keyframe",
"ti ti-lock", "ti ti-lock-access", "ti ti-lock-check", "ti ti-shield", "ti ti-shield-check", "ti ti-shield-lock",
"ti ti-shield-half-filled", "ti ti-shield-x", "ti ti-eye-shield", "ti ti-fingerprint", "ti ti-automation", "ti ti-robot",
"ti ti-settings", "ti ti-settings-2", "ti ti-settings-automation", "ti ti-adjustments", "ti ti-adjustments-alt", "ti ti-adjustments-bolt",
"ti ti-tool", "ti ti-tools", "ti ti-wrench", "ti ti-hammer", "ti ti-screwdriver", "ti ti-stethoscope",
"ti ti-tags", "ti ti-tag", "ti ti-tag-starred", "ti ti-pin", "ti ti-pinned", "ti ti-bookmark",
"ti ti-flag", "ti ti-flag-2", "ti ti-user-cog", "ti ti-user-shield", "ti ti-users-group", "ti ti-building-community",
}
}
func hasUpdateDay(days, code string) bool {
for _, item := range strings.Split(strings.ToLower(strings.TrimSpace(days)), ",") {
if strings.TrimSpace(item) == strings.ToLower(strings.TrimSpace(code)) {
return true
}
}
return false
}
func updateWindowSummary(start, end, days string) string {
if strings.TrimSpace(start) == "" || strings.TrimSpace(end) == "" {
if strings.TrimSpace(days) == "" {
return "Any time"
}
return strings.ToUpper(strings.ReplaceAll(days, ",", " "))
}
if strings.TrimSpace(days) == "" {
return start + " - " + end
}
return strings.ToUpper(strings.ReplaceAll(days, ",", " ")) + " · " + start + " - " + end
}
func icon(name string) template.HTML {
icons := map[string]string{
"dashboard": `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 13h7V4H4zm9 7h7v-9h-7zM4 20h7v-5H4zm9-9h7V4h-7z"/></svg>`,

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -8,6 +8,33 @@ document.addEventListener("DOMContentLoaded", () => {
const dashboardNodes = document.getElementById("dashboard-nodes");
const dashboardSearch = document.getElementById("dashboard-search");
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 commandLogModal = document.getElementById("commandLogModal");
const updatePackagesModal = document.getElementById("updatePackagesModal");
const editGroupModal = document.getElementById("editGroupModal");
const groupIconPickerModal = document.getElementById("groupIconPickerModal");
const groupIconSearch = document.querySelector("[data-group-icon-search]");
const groupIconGrid = document.querySelector("[data-group-icon-grid]");
let activeGroupIconInput = null;
let activeGroupIconPreview = "";
const setGroupIconPreview = (previewId, iconClass) => {
const preview = document.getElementById(previewId);
if (!(preview instanceof HTMLElement)) {
return;
}
preview.innerHTML = `<i class="${iconClass}"></i>`;
};
const highlightSelectedGroupIcon = (iconClass) => {
groupIconGrid?.querySelectorAll("[data-icon-option]").forEach((option) => {
if (option instanceof HTMLElement) {
option.classList.toggle("is-active", option.dataset.iconOption === iconClass);
}
});
};
const attachXtermConsole = (consoleOutput) => {
const wsPath = consoleOutput.dataset.ws;
@@ -76,6 +103,177 @@ document.addEventListener("DOMContentLoaded", () => {
attachXtermConsole(output);
}
if (commandLogModal instanceof HTMLElement) {
commandLogModal.addEventListener("show.bs.modal", (event) => {
const trigger = event.relatedTarget;
if (!(trigger instanceof HTMLElement)) {
return;
}
const target = trigger.dataset.logTarget || "";
const time = trigger.dataset.logTime || "";
const status = trigger.dataset.logStatus || "";
const duration = trigger.dataset.logDuration || "";
const command = trigger.dataset.logCommand || "";
const outputText = trigger.dataset.logOutput || "";
const meta = commandLogModal.querySelector("[data-command-log-meta]");
const commandNode = commandLogModal.querySelector("[data-command-log-command]");
const outputNode = commandLogModal.querySelector("[data-command-log-output]");
if (meta) {
meta.textContent = [target, time, status, duration].filter(Boolean).join(" · ");
}
if (commandNode) {
commandNode.textContent = command || "-";
}
if (outputNode) {
outputNode.textContent = outputText || "No output";
}
});
}
if (updatePackagesModal instanceof HTMLElement) {
updatePackagesModal.addEventListener("show.bs.modal", (event) => {
const trigger = event.relatedTarget;
if (!(trigger instanceof HTMLElement)) {
return;
}
const nodeName = trigger.dataset.nodeName || "";
const packageCount = trigger.dataset.packageCount || "0";
const rawPackages = trigger.dataset.packages || "[]";
const nodeLabel = updatePackagesModal.querySelector("[data-update-packages-node]");
const body = updatePackagesModal.querySelector("[data-update-packages-body]");
const countValue = Number.parseInt(packageCount, 10);
const countLabel = Number.isFinite(countValue) && countValue === 1 ? "1 pending package" : `${packageCount} pending packages`;
if (nodeLabel) {
nodeLabel.textContent = `${nodeName} · ${countLabel}`;
}
if (!(body instanceof HTMLElement)) {
return;
}
let packages = [];
try {
packages = JSON.parse(rawPackages);
} catch (_) {
packages = [];
}
if (!Array.isArray(packages) || packages.length === 0) {
body.innerHTML = `<tr><td colspan="4" class="text-body-secondary text-center py-4">No pending packages.</td></tr>`;
return;
}
body.innerHTML = packages.map((pkg) => `
<tr>
<td class="fw-semibold">${pkg.Name || "-"}</td>
<td class="text-body-secondary">${pkg.Current || "-"}</td>
<td>${pkg.Available || "-"}</td>
<td class="text-body-secondary">${pkg.Architecture || "-"}</td>
</tr>
`).join("");
});
}
if (editGroupModal instanceof HTMLElement) {
editGroupModal.addEventListener("show.bs.modal", (event) => {
const trigger = event.relatedTarget;
if (!(trigger instanceof HTMLElement)) {
return;
}
const groupId = trigger.dataset.groupId || "";
const groupName = trigger.dataset.groupName || "";
const groupDescription = trigger.dataset.groupDescription || "";
const groupIcon = trigger.dataset.groupIcon || "ti ti-stack-2";
const form = editGroupModal.querySelector("[data-edit-group-form]");
const nameInput = editGroupModal.querySelector("[data-edit-group-name]");
const descriptionInput = editGroupModal.querySelector("[data-edit-group-description]");
const iconInput = document.getElementById("editGroupIconValue");
const iconTrigger = editGroupModal.querySelector("[data-edit-group-icon-trigger]");
if (form instanceof HTMLFormElement) {
form.action = `/groups/${groupId}`;
}
if (nameInput instanceof HTMLInputElement) {
nameInput.value = groupName;
}
if (descriptionInput instanceof HTMLTextAreaElement) {
descriptionInput.value = groupDescription;
}
if (iconInput instanceof HTMLInputElement) {
iconInput.value = groupIcon;
}
setGroupIconPreview("editGroupIconPreview", groupIcon);
if (iconTrigger instanceof HTMLElement) {
iconTrigger.dataset.iconValue = groupIcon;
}
});
}
if (groupIconPickerModal instanceof HTMLElement) {
groupIconPickerModal.addEventListener("show.bs.modal", (event) => {
const trigger = event.relatedTarget;
if (!(trigger instanceof HTMLElement)) {
return;
}
activeGroupIconInput = document.getElementById(trigger.dataset.iconInput || "");
activeGroupIconPreview = trigger.dataset.iconPreview || "";
const currentValue = trigger.dataset.iconValue || (activeGroupIconInput instanceof HTMLInputElement ? activeGroupIconInput.value : "ti ti-stack-2");
if (groupIconSearch instanceof HTMLInputElement) {
groupIconSearch.value = "";
}
groupIconGrid?.querySelectorAll("[data-icon-option]").forEach((option) => {
if (option instanceof HTMLElement) {
option.classList.remove("d-none");
}
});
highlightSelectedGroupIcon(currentValue);
window.setTimeout(() => groupIconSearch?.focus(), 120);
});
groupIconGrid?.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const option = target.closest("[data-icon-option]");
if (!(option instanceof HTMLElement) || !(activeGroupIconInput instanceof HTMLInputElement)) {
return;
}
const iconClass = option.dataset.iconOption || "ti ti-stack-2";
activeGroupIconInput.value = iconClass;
if (activeGroupIconPreview) {
setGroupIconPreview(activeGroupIconPreview, iconClass);
}
document.querySelectorAll(`[data-icon-input="${activeGroupIconInput.id}"]`).forEach((trigger) => {
if (trigger instanceof HTMLElement) {
trigger.dataset.iconValue = iconClass;
}
});
highlightSelectedGroupIcon(iconClass);
bootstrap.Modal.getInstance(groupIconPickerModal)?.hide();
});
groupIconSearch?.addEventListener("input", () => {
const query = groupIconSearch.value.trim().toLowerCase();
groupIconGrid?.querySelectorAll("[data-icon-option]").forEach((option) => {
if (!(option instanceof HTMLElement)) {
return;
}
const label = option.dataset.iconOption || "";
option.classList.toggle("d-none", query !== "" && !label.toLowerCase().includes(query));
});
});
}
const syncScheduleUi = () => {
if (!triggerType || !scheduleKind) {
return;
@@ -152,32 +350,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);
}
};
@@ -222,19 +432,20 @@ document.addEventListener("DOMContentLoaded", () => {
if (dashboardNodes instanceof HTMLElement) {
const url = dashboardNodes.dataset.dashboardNodesUrl;
if (url) {
fetch(url, { headers: { Accept: "text/html" } })
.then((response) => {
const loadDashboardNodes = async (showErrorState) => {
try {
const response = await fetch(url, { headers: { Accept: "text/html" } });
if (!response.ok) {
throw new Error("failed");
}
return response.text();
})
.then((html) => {
const html = await response.text();
dashboardNodes.innerHTML = html;
dashboardNodes.classList.add("is-loaded");
dashboardSearch?.dispatchEvent(new Event("input"));
})
.catch(() => {
} catch (_) {
if (!showErrorState) {
return;
}
dashboardNodes.innerHTML = `
<div class="card border-0 shadow-sm">
<div class="card-body py-5 text-center text-body-secondary">
@@ -242,7 +453,13 @@ document.addEventListener("DOMContentLoaded", () => {
</div>
</div>
`;
});
}
};
loadDashboardNodes(true);
window.setInterval(() => {
loadDashboardNodes(false);
}, 5000);
}
}
@@ -294,6 +511,143 @@ document.addEventListener("DOMContentLoaded", () => {
syncAuthMode();
}
if (themeModeInput instanceof HTMLInputElement) {
themeRadios.forEach((radio) => {
radio.addEventListener("change", () => {
if (radio instanceof HTMLInputElement && radio.checked) {
themeModeInput.value = radio.value;
}
});
});
}
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);