dev #8

Merged
GigabiteStudios merged 14 commits from dev into main 2026-06-21 02:02:38 +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
@@ -24,4 +24,4 @@ jobs:
run: go build -v ./...
- name: Run Tests
run: go test -v ./...
run: go test -v ./...

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

@@ -11,11 +11,14 @@ const (
)
type Organization struct {
ID int64
Name string
Theme string
ThemeMode string
CreatedAt time.Time
ID int64
Name string
Theme string
ThemeMode string
AutoUpdateWindowStart string
AutoUpdateWindowEnd string
AutoUpdateDays string
CreatedAt time.Time
}
type User struct {
@@ -27,6 +30,7 @@ type User struct {
Role Role
OTPSecret string
OTPEnabled bool
ThemeMode string
CreatedAt time.Time
}
@@ -36,42 +40,58 @@ type VMGroup struct {
Name string
Description string
ColorToken string
Icon string
CreatedAt time.Time
}
type Node struct {
ID int64
OrganizationID int64
GroupID *int64
GroupName string
Tag string
Name string
Distro string
Hostname string
IPAddress string
MACAddress string
SSHPort int
SSHUsername string
SSHPassword string
PackageManager string
Architecture string
HostModel string
KernelVersion string
CPUModel string
GPUModel string
DefaultShell string
PackageCount int64
MemoryTotalMB int64
DiskTotalGB int64
CPUUsage float64
RAMUsage float64
DiskUsage float64
UptimeSeconds int64
LastSeenAt *time.Time
AutoUpdatesEnabled bool
Notes string
CreatedAt time.Time
UpdatedAt time.Time
ID int64
OrganizationID int64
GroupID *int64
GroupName string
Tag string
Name string
Distro string
Hostname string
IPAddress string
MACAddress string
SSHPort int
SSHUsername string
SSHPassword string
PackageManager string
Architecture string
HostModel string
KernelVersion string
CPUModel string
GPUModel string
DefaultShell string
PackageCount int64
MemoryTotalMB int64
DiskTotalGB int64
CPUUsage float64
RAMUsage float64
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 {
@@ -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,13 +1,12 @@
{{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">
{{template "content" .}}
</div>
</div>
<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>

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

View File

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

View File

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

View File

@@ -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);