dev #8
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.gitea
|
||||
data
|
||||
tmp
|
||||
dist
|
||||
bin
|
||||
*.log
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
coverage.out
|
||||
node_modules
|
||||
@@ -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
|
||||
|
||||
235
.gitea/workflows/release.yml
Normal file
235
.gitea/workflows/release.yml
Normal 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
|
||||
@@ -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
34
Dockerfile
Normal 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"]
|
||||
22
README.md
22
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,94 +2,102 @@
|
||||
{{$data := .Content}}
|
||||
<section class="mb-4">
|
||||
<div>
|
||||
<div class="text-uppercase small fw-semibold text-primary mb-2">Control Plane</div>
|
||||
<h1 class="display-6 fw-bold mb-2">Jobs</h1>
|
||||
<div class="text-uppercase small fw-semibold text-primary mb-2"><i class="ti ti-cpu-2 me-1"></i>Control Plane</div>
|
||||
<h1 class="display-6 fw-bold mb-2"><i class="ti ti-clock-cog me-2"></i>Jobs</h1>
|
||||
<p class="text-body-secondary mb-0">Schedules, targets, runs, history, output.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="row g-4 mb-4">
|
||||
<div class="col-12">
|
||||
<article class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">Scheduled Jobs</h2>
|
||||
<p class="text-body-secondary mb-0">Node, group, or tag targets with next run, last run, and command order.</p>
|
||||
</div>
|
||||
<span class="badge text-bg-primary">{{len $data.Jobs}} jobs</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
{{range $data.Jobs}}
|
||||
<div class="col-12 col-xxl-6">
|
||||
<div class="border rounded-4 p-3 bg-body-tertiary h-100">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||
<article class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<strong>{{.Name}}</strong>
|
||||
<div class="small text-body-secondary">{{.TriggerType}} · {{if .NodeName}}{{.NodeName}}{{else if .GroupName}}{{.GroupName}}{{else if .Tag}}tag:{{.Tag}}{{else}}unassigned{{end}}</div>
|
||||
<div class="small text-body-secondary">{{if .Schedule}}{{.Schedule}}{{else}}manual{{end}}</div>
|
||||
<h2 class="h4 mb-1"><i class="ti ti-calendar-time me-2"></i>Scheduled Jobs</h2>
|
||||
<p class="text-body-secondary mb-0">Node, group, or tag targets with next run, last run, and command order.</p>
|
||||
</div>
|
||||
<span class="badge {{if .Enabled}}text-bg-success{{else}}text-bg-secondary{{end}}">{{if .Enabled}}Enabled{{else}}Paused{{end}}</span>
|
||||
<span class="badge text-bg-primary">{{len $data.Jobs}} jobs</span>
|
||||
</div>
|
||||
<div class="row g-3 small mb-3">
|
||||
<div class="col-sm-4">
|
||||
<div class="text-body-secondary">Last run</div>
|
||||
<div>{{if .LastRunAt}}{{.LastRunAt.Format "2006-01-02 15:04"}}{{else}}Never{{end}}</div>
|
||||
<div class="row g-3">
|
||||
{{range $data.Jobs}}
|
||||
<div class="col-12 col-xxl-6">
|
||||
<div class="border rounded-4 p-3 bg-body-tertiary h-100">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
<strong><i class="ti ti-bolt me-2"></i>{{.Name}}</strong>
|
||||
<div class="small text-body-secondary">{{.TriggerType}} · {{if .NodeName}}{{.NodeName}}{{else if .GroupName}}{{.GroupName}}{{else if .Tag}}tag:{{.Tag}}{{else}}unassigned{{end}}</div>
|
||||
<div class="small text-body-secondary">{{if .Schedule}}{{.Schedule}}{{else}}manual{{end}}</div>
|
||||
</div>
|
||||
<span class="badge {{if .Enabled}}text-bg-success{{else}}text-bg-secondary{{end}}">{{if .Enabled}}Enabled{{else}}Paused{{end}}</span>
|
||||
</div>
|
||||
<div class="row g-3 small mb-3">
|
||||
<div class="col-sm-4">
|
||||
<div class="text-body-secondary">Last run</div>
|
||||
<div>{{if .LastRunAt}}{{.LastRunAt.Format "2006-01-02 15:04"}}{{else}}Never{{end}}</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="text-body-secondary">Next run</div>
|
||||
<div>{{if .NextRunAt}}{{.NextRunAt.Format "2006-01-02 15:04"}}{{else}}Manual{{end}}</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="text-body-secondary">Target</div>
|
||||
<div>{{if .NodeName}}Node{{else if .GroupName}}Group{{else if .Tag}}Tag{{else}}None{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="mb-0 ps-3 small">
|
||||
{{range (splitLines .Command)}}
|
||||
<li class="mb-1"><code>{{.}}</code></li>
|
||||
{{end}}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="text-body-secondary">Next run</div>
|
||||
<div>{{if .NextRunAt}}{{.NextRunAt.Format "2006-01-02 15:04"}}{{else}}Manual{{end}}</div>
|
||||
{{else}}
|
||||
<div class="col-12">
|
||||
<div class="text-center text-body-secondary py-5">
|
||||
<i class="ti ti-clock-off fs-1 d-block mb-3"></i>No jobs yet.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="text-body-secondary">Target</div>
|
||||
<div>{{if .NodeName}}Node{{else if .GroupName}}Group{{else if .Tag}}Tag{{else}}None{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="mb-0 ps-3 small">
|
||||
{{range (splitLines .Command)}}
|
||||
<li class="mb-1"><code>{{.}}</code></li>
|
||||
{{end}}
|
||||
</ol>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="row g-4">
|
||||
<div class="col-12">
|
||||
<article class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">Job History</h2>
|
||||
<p class="text-body-secondary mb-0">Run duration, output, target node, and status.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vstack gap-3">
|
||||
{{range $data.Runs}}
|
||||
<div class="border rounded-4 p-3 bg-body-tertiary">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
|
||||
<article class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<strong>{{if .JobName}}{{.JobName}}{{else}}{{.Action}}{{end}}</strong>
|
||||
<div class="small text-body-secondary">{{.NodeName}} · {{.StartedAt.Format "2006-01-02 15:04"}}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div><span class="badge {{if eq .Status "completed"}}text-bg-success{{else}}text-bg-danger{{end}}">{{.Status}}</span></div>
|
||||
<div class="small text-body-secondary mt-1">{{.DurationText}}</div>
|
||||
<h2 class="h4 mb-1"><i class="ti ti-history me-2"></i>Job History</h2>
|
||||
<p class="text-body-secondary mb-0">Run duration, output, target node, and status.</p>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="run-pre mb-0">{{.Output}}</pre>
|
||||
<div class="vstack gap-3">
|
||||
{{range $data.Runs}}
|
||||
<div class="border rounded-4 p-3 bg-body-tertiary">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
|
||||
<div>
|
||||
<strong><i class="ti ti-terminal-2 me-2"></i>{{if .JobName}}{{.JobName}}{{else}}{{.Action}}{{end}}</strong>
|
||||
<div class="small text-body-secondary">{{.NodeName}} · {{.StartedAt.Format "2006-01-02 15:04"}}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div><span class="badge {{if eq .Status "completed"}}text-bg-success{{else}}text-bg-danger{{end}}">{{.Status}}</span></div>
|
||||
<div class="small text-body-secondary mt-1">{{.DurationText}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="run-pre mb-0">{{.Output}}</pre>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center text-body-secondary py-5">
|
||||
<i class="ti ti-history-off fs-1 d-block mb-3"></i>No job runs yet.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-body-secondary">No job runs yet.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal fade" id="addJobModal" tabindex="-1" aria-hidden="true">
|
||||
@@ -97,7 +105,7 @@
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<form method="post" action="/automations" id="job-form">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5">Create Job</h2>
|
||||
<h2 class="modal-title fs-5"><i class="ti ti-clock-cog me-2"></i>Create Job</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -189,7 +197,7 @@
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label class="form-label mb-0">Commands</label>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="add-command-step">Add step</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="add-command-step"><i class="ti ti-plus me-1"></i>Add step</button>
|
||||
</div>
|
||||
<div id="command-steps" class="vstack gap-2">
|
||||
<div class="input-group command-step">
|
||||
@@ -201,7 +209,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal"><i class="ti ti-x me-1"></i>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Create Job</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{{define "content"}}
|
||||
{{$data := .Content}}
|
||||
<section class="console-panel">
|
||||
<div id="console" class="console-output console-terminal" data-ws="/nodes/{{$data.Node.ID}}/console/ws" data-xterm="true"></div>
|
||||
<section class="console-page">
|
||||
<div class="console-page-terminal">
|
||||
<div id="console" class="console-output console-terminal console-terminal-fullscreen" data-ws="/nodes/{{$data.Node.ID}}/console/ws" data-xterm="true"></div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -4,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">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{{define "content"}}
|
||||
<div class="mb-4">
|
||||
<h2 class="fw-bold mb-0">Verify OTP</h2>
|
||||
<h2 class="fw-bold mb-0"><i class="ti ti-shield-check me-2"></i>Verify OTP</h2>
|
||||
</div>
|
||||
{{with .Content}}{{with .Error}}<div class="alert alert-danger">{{.}}</div>{{end}}{{end}}
|
||||
<form method="post">
|
||||
<div class="mb-4">
|
||||
<label class="form-label">6-digit code</label>
|
||||
<label class="form-label"><i class="ti ti-keyboard me-2"></i>6-digit code</label>
|
||||
<input type="text" class="form-control form-control-lg" name="code" inputmode="numeric" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100"><i class="ti ti-lock-check me-2"></i>Unlock</button>
|
||||
|
||||
@@ -2,11 +2,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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
264
internal/views/pages/updates.gohtml
Normal file
264
internal/views/pages/updates.gohtml
Normal 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}}
|
||||
206
internal/views/pages/uptime.gohtml
Normal file
206
internal/views/pages/uptime.gohtml
Normal 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}}
|
||||
192
internal/views/pages/user_settings.gohtml
Normal file
192
internal/views/pages/user_settings.gohtml
Normal file
@@ -0,0 +1,192 @@
|
||||
{{define "content"}}
|
||||
{{$data := .Content}}
|
||||
<section class="mb-4">
|
||||
<div>
|
||||
<div class="text-uppercase small fw-semibold text-primary mb-2"><i class="ti ti-user-cog me-1"></i>Account</div>
|
||||
<h1 class="display-6 fw-bold mb-2"><i class="ti ti-settings-2 me-2"></i>User Settings</h1>
|
||||
<p class="text-body-secondary mb-0">Password, 2FA, and your display mode.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if $data.Message}}
|
||||
<div class="alert alert-success border-0 shadow-sm"><i class="ti ti-circle-check me-2"></i>{{$data.Message}}</div>
|
||||
{{end}}
|
||||
{{if $data.Error}}
|
||||
<div class="alert alert-danger border-0 shadow-sm"><i class="ti ti-alert-circle me-2"></i>{{$data.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<section class="row g-4">
|
||||
<div class="col-12 col-xl-4">
|
||||
<article class="card border-0 shadow-sm settings-nav-card h-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center gap-3 mb-4">
|
||||
<div class="brand-mark-lg">
|
||||
<i class="ti ti-user"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="fw-bold text-body-emphasis text-truncate">{{with .User}}{{.Name}}{{end}}</div>
|
||||
<div class="small text-body-secondary text-truncate">{{with .User}}{{.Email}}{{end}}</div>
|
||||
<div class="small text-body-secondary text-truncate text-uppercase">{{with .User}}{{.Role}}{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-nav-list">
|
||||
<a class="settings-nav-link is-active" href="#account-security"><i class="ti ti-lock"></i><span>Security</span></a>
|
||||
<a class="settings-nav-link" href="#account-appearance"><i class="ti ti-sun-moon"></i><span>Appearance</span></a>
|
||||
<a class="settings-nav-link" href="#account-profile"><i class="ti ti-user-circle"></i><span>Profile</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="vstack gap-4">
|
||||
<article class="card border-0 shadow-sm settings-section-card" id="account-profile">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="h4 mb-1"><i class="ti ti-user-circle me-2"></i>Profile</h2>
|
||||
<div class="small text-body-secondary">Current account details.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="settings-info-tile">
|
||||
<div class="small text-uppercase text-body-secondary fw-semibold mb-2">Name</div>
|
||||
<div class="fw-semibold">{{with .User}}{{.Name}}{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="settings-info-tile">
|
||||
<div class="small text-uppercase text-body-secondary fw-semibold mb-2">Email</div>
|
||||
<div class="fw-semibold">{{with .User}}{{.Email}}{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="settings-info-tile">
|
||||
<div class="small text-uppercase text-body-secondary fw-semibold mb-2">Role</div>
|
||||
<div class="fw-semibold">{{with .User}}{{.Role}}{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="settings-info-tile">
|
||||
<div class="small text-uppercase text-body-secondary fw-semibold mb-2">2FA</div>
|
||||
<div class="fw-semibold">{{if $data.OTPEnabled}}Enabled{{else}}Disabled{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card border-0 shadow-sm settings-section-card" id="account-security">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="h4 mb-1"><i class="ti ti-lock me-2"></i>Password</h2>
|
||||
<div class="small text-body-secondary">Update your sign-in password.</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/settings/user/password">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Current password</label>
|
||||
<input type="password" class="form-control" name="current_password" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">New password</label>
|
||||
<input type="password" class="form-control" name="new_password" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Confirm password</label>
|
||||
<input type="password" class="form-control" name="confirm_password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary"><i class="ti ti-device-floppy me-2"></i>Save password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card border-0 shadow-sm settings-section-card">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="h4 mb-1"><i class="ti ti-shield-lock me-2"></i>Two-factor authentication</h2>
|
||||
<div class="small text-body-secondary">Manage OTP authentication for your account.</div>
|
||||
</div>
|
||||
{{if $data.OTPEnabled}}
|
||||
<span class="badge text-bg-success">Enabled</span>
|
||||
{{else}}
|
||||
<span class="badge text-bg-secondary">Pending</span>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if $data.OTPEnabled}}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<form method="post" action="/settings/user/2fa/reset">
|
||||
<button type="submit" class="btn btn-outline-primary"><i class="ti ti-refresh me-2"></i>Reset 2FA</button>
|
||||
</form>
|
||||
<form method="post" action="/settings/user/2fa/disable">
|
||||
<button type="submit" class="btn btn-outline-danger"><i class="ti ti-lock-off me-2"></i>Disable 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="row g-4 align-items-center">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="otp-qr-card text-center">
|
||||
<img alt="OTP QR code" src="{{$data.OTPQRCode}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-8">
|
||||
<form method="post" action="/settings/user/2fa/enable">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Secret</label>
|
||||
<input type="text" class="form-control" readonly value="{{$data.OTPSecret}}">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Authenticator code</label>
|
||||
<input type="text" class="form-control" name="code" inputmode="numeric" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="ti ti-shield-check me-2"></i>Enable 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card border-0 shadow-sm settings-section-card" id="account-appearance">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="h4 mb-1"><i class="ti ti-sun-moon me-2"></i>Appearance</h2>
|
||||
<div class="small text-body-secondary">Choose how Maintainarr looks for you.</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/settings/user/theme">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<input type="radio" class="btn-check" name="mode" id="user-theme-dark" value="dark" {{if eq $data.CurrentMode "dark"}}checked{{end}}>
|
||||
<label class="theme-card d-block h-100" for="user-theme-dark">
|
||||
<span class="theme-swatch swatch-dark"></span>
|
||||
<strong>Dark</strong>
|
||||
<small>Default night view</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<input type="radio" class="btn-check" name="mode" id="user-theme-light" value="light" {{if eq $data.CurrentMode "light"}}checked{{end}}>
|
||||
<label class="theme-card d-block h-100" for="user-theme-light">
|
||||
<span class="theme-swatch swatch-light"></span>
|
||||
<strong>Light</strong>
|
||||
<small>Bright daytime view</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="ti ti-device-floppy me-2"></i>Save appearance</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -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
BIN
web/static/img/favicon-rounded.png
Normal file
BIN
web/static/img/favicon-rounded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user