Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e907f8455 | ||
|
|
a987667233 | ||
|
|
58ec47b188 | ||
|
|
2485eae120 | ||
|
|
3f881a59e5 | ||
|
|
22f40f3830 |
43
README.md
43
README.md
@@ -28,10 +28,18 @@
|
||||
информационные сообщения по CPU, памяти, storage, SMART, ZFS, UPS, VM/LXC и
|
||||
резервным копиям. Критические события всегда отображаются первыми.
|
||||
|
||||
Во вкладке `Настройки → Почта` настраиваются SMTP-сервер, получатель и интервал
|
||||
агрегированной сводки. Можно отдельно выбрать уровни важности и источники
|
||||
событий — например, полностью исключить уведомления о резервных копиях. Там же
|
||||
доступна отправка тестового письма. Поддерживаются STARTTLS (обычно порт 587) и
|
||||
SMTP поверх TLS (порт 465).
|
||||
|
||||
Пороги уведомлений сохраняются в SQLite и настраиваются через вкладку
|
||||
`Настройки → Уведомления`. Панель раз в минуту сохраняет историю CPU,
|
||||
температуры, памяти, swap, сети, storage, ZFS, UPS и гостей. Данные старше семи
|
||||
дней удаляются автоматически. Графики находятся внутри подробных окон карточек.
|
||||
дней удаляются автоматически. В подробных окнах карточек текущие показатели и
|
||||
история разделены на вкладки. Для графиков можно выбрать последний час, 24 часа
|
||||
или 7 дней, а точные значения и время отображаются при наведении.
|
||||
|
||||
База данных по умолчанию находится здесь:
|
||||
|
||||
@@ -42,6 +50,13 @@
|
||||
Готовые версии для Linux публикуются в разделе Releases репозитория. Бинарник
|
||||
не требует установки Go или системной библиотеки SQLite.
|
||||
|
||||
Проверка обновлений выполняется отдельным systemd-таймером раз в сутки. Статус
|
||||
виден во вкладке `Настройки → Обновления` и в центре уведомлений. Доступны два
|
||||
режима: `notify` только сообщает о новой версии, `auto` скачивает её, сохраняет
|
||||
предыдущий бинарник как резервный и перезапускает панель. В режиме `notify`
|
||||
обновление можно запустить кнопкой `Проверить и установить обновление` в
|
||||
настройках. Запрос выполняет отдельная системная служба.
|
||||
|
||||
На главном экране находятся компактные карточки CPU, памяти, диска и сети.
|
||||
Нажатие на карточку открывает подробные показатели.
|
||||
|
||||
@@ -54,6 +69,32 @@ chmod +x proxmox-cpu-dashboard-linux-amd64
|
||||
./proxmox-cpu-dashboard-linux-amd64
|
||||
```
|
||||
|
||||
## Установка на сервер и обновления
|
||||
|
||||
Скачайте из Releases архив `proxmox-dashboard-install-amd64.tar.gz`, распакуйте
|
||||
его на Proxmox и запустите от root:
|
||||
|
||||
```bash
|
||||
tar -xzf proxmox-dashboard-install-amd64.tar.gz
|
||||
cd proxmox-dashboard-install
|
||||
UPDATE_MODE=notify ./scripts/install.sh
|
||||
```
|
||||
|
||||
Для полностью автоматических обновлений используйте `UPDATE_MODE=auto`.
|
||||
Приватному репозиторию нужен read-only API-токен Gitea:
|
||||
|
||||
```bash
|
||||
GITEA_TOKEN='read-only-token' UPDATE_MODE=auto ./scripts/install.sh
|
||||
```
|
||||
|
||||
Токен сохраняется с правами `0600` в `/etc/proxmox-dashboard/update.env`.
|
||||
Проверить обновления вручную можно командой:
|
||||
|
||||
```bash
|
||||
systemctl start proxmox-dashboard-update.service
|
||||
systemctl status proxmox-dashboard-update.service
|
||||
```
|
||||
|
||||
Откройте:
|
||||
|
||||
```text
|
||||
|
||||
@@ -20,6 +20,11 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
|
||||
add := func(id, severity, source, title, message string) {
|
||||
alerts = append(alerts, Alert{ID: id, Severity: severity, Source: source, Title: title, Message: message})
|
||||
}
|
||||
if metrics.Update.State == "available" {
|
||||
add("dashboard-update", "info", "Dashboard", "Доступно обновление "+metrics.Update.AvailableVersion, "Режим обновлений: только уведомлять. Установите обновление с сервера или включите автоматический режим.")
|
||||
} else if metrics.Update.State == "error" {
|
||||
add("dashboard-update-error", "warning", "Dashboard", "Не удалось проверить обновления", metrics.Update.Message)
|
||||
}
|
||||
|
||||
if metrics.CPU.UsagePercent >= thresholds.CPUCritical {
|
||||
add("cpu-usage", "critical", "CPU", "Очень высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent))
|
||||
|
||||
131
email.go
Normal file
131
email.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func filterEmailAlerts(alerts []Alert, settings EmailSettings) []Alert {
|
||||
allowedSeverity, allowedSource := map[string]bool{}, map[string]bool{}
|
||||
for _, value := range settings.Severities {
|
||||
allowedSeverity[value] = true
|
||||
}
|
||||
for _, value := range settings.Sources {
|
||||
allowedSource[value] = true
|
||||
}
|
||||
var result []Alert
|
||||
for _, alert := range alerts {
|
||||
if allowedSeverity[alert.Severity] && allowedSource[alert.Source] {
|
||||
result = append(result, alert)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sendAlertEmail(settings EmailSettings, alerts []Alert, test bool) error {
|
||||
subject := "Proxmox Dashboard: сводка уведомлений"
|
||||
body := "Система не обнаружила выбранных активных событий."
|
||||
if test {
|
||||
subject = "Proxmox Dashboard: тест почты"
|
||||
body = "Почтовые уведомления настроены правильно."
|
||||
}
|
||||
if len(alerts) > 0 {
|
||||
groups := map[string][]Alert{}
|
||||
for _, alert := range alerts {
|
||||
groups[alert.Severity] = append(groups[alert.Severity], alert)
|
||||
}
|
||||
var lines []string
|
||||
for _, severity := range []string{"critical", "warning", "info"} {
|
||||
items := groups[severity]
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
title := map[string]string{"critical": "КРИТИЧЕСКИЕ", "warning": "ПРЕДУПРЕЖДЕНИЯ", "info": "ИНФОРМАЦИЯ"}[severity]
|
||||
lines = append(lines, title)
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Title < items[j].Title })
|
||||
for _, item := range items {
|
||||
lines = append(lines, fmt.Sprintf("• [%s] %s\n %s", item.Source, item.Title, item.Message))
|
||||
}
|
||||
}
|
||||
body = strings.Join(lines, "\n\n")
|
||||
subject = fmt.Sprintf("Proxmox Dashboard: событий — %d", len(alerts))
|
||||
}
|
||||
message := []byte("From: " + settings.From + "\r\nTo: " + settings.To + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n" + body + "\r\n")
|
||||
address := net.JoinHostPort(settings.Host, fmt.Sprint(settings.Port))
|
||||
tlsConfig := &tls.Config{ServerName: settings.Host, MinVersion: tls.VersionTLS12, InsecureSkipVerify: settings.SkipTLSVerify} //nolint:gosec -- explicit private-network option
|
||||
var client *smtp.Client
|
||||
if settings.Port == 465 {
|
||||
connection, err := tls.Dial("tcp", address, tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err = smtp.NewClient(connection, settings.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
client, err = smtp.Dial(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(tlsConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
defer client.Close()
|
||||
if settings.Username != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", settings.Username, settings.Password, settings.Host)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(settings.From); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(settings.To); err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = writer.Write(message); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func runEmailNotifier(collect func() (dashboardMetrics, error), store *Store) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
settings := store.EmailSettings(true)
|
||||
if !settings.Enabled || time.Since(store.LastEmailAt()) < time.Duration(settings.IntervalMinutes)*time.Minute {
|
||||
continue
|
||||
}
|
||||
metrics, err := collect()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
alerts := filterEmailAlerts(metrics.Alerts, settings)
|
||||
if len(alerts) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := sendAlertEmail(settings, alerts, false); err != nil {
|
||||
log.Printf("Не удалось отправить почтовую сводку: %v", err)
|
||||
continue
|
||||
}
|
||||
store.SetLastEmailAt(time.Now())
|
||||
}
|
||||
}
|
||||
40
email_test.go
Normal file
40
email_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterEmailAlerts(t *testing.T) {
|
||||
alerts := []Alert{
|
||||
{ID: "cpu", Severity: "critical", Source: "CPU"},
|
||||
{ID: "backup", Severity: "critical", Source: "Backups"},
|
||||
{ID: "info", Severity: "info", Source: "CPU"},
|
||||
}
|
||||
settings := EmailSettings{Severities: []string{"critical"}, Sources: []string{"CPU"}}
|
||||
filtered := filterEmailAlerts(alerts, settings)
|
||||
if len(filtered) != 1 || filtered[0].ID != "cpu" {
|
||||
t.Fatalf("unexpected filtered alerts: %+v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailPasswordIsPreserved(t *testing.T) {
|
||||
store, err := openStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
settings := defaultEmailSettings()
|
||||
settings.Host, settings.From, settings.To, settings.Password = "smtp.example.com", "dash@example.com", "admin@example.com", "secret"
|
||||
if err := store.SaveEmailSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Password = ""
|
||||
settings.IntervalMinutes = 120
|
||||
if err := store.SaveEmailSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := store.EmailSettings(true); got.Password != "secret" || got.IntervalMinutes != 120 {
|
||||
t.Fatalf("unexpected settings: %+v", got)
|
||||
}
|
||||
if got := store.EmailSettings(false); got.Password != "" || !got.HasPassword {
|
||||
t.Fatalf("password leaked or marker lost: %+v", got)
|
||||
}
|
||||
}
|
||||
8
main.go
8
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -18,6 +19,13 @@ const defaultDatabasePath = "/var/lib/proxmox-dashboard/dashboard.db"
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
showVersion := flag.Bool("version", false, "show version")
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
fmt.Println(version)
|
||||
return
|
||||
}
|
||||
|
||||
address := os.Getenv("DASHBOARD_ADDR")
|
||||
if address == "" {
|
||||
address = defaultAddress
|
||||
|
||||
9
proxmox-dashboard-update.path
Normal file
9
proxmox-dashboard-update.path
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Watch for manual Proxmox Dashboard update requests
|
||||
|
||||
[Path]
|
||||
PathExists=/var/lib/proxmox-dashboard/update-request
|
||||
Unit=proxmox-dashboard-update.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
10
proxmox-dashboard-update.service
Normal file
10
proxmox-dashboard-update.service
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Check and install Proxmox Dashboard updates
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/proxmox-dashboard-update
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
11
proxmox-dashboard-update.timer
Normal file
11
proxmox-dashboard-update.timer
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Check Proxmox Dashboard updates daily
|
||||
|
||||
[Timer]
|
||||
OnBootSec=10min
|
||||
OnUnitActiveSec=24h
|
||||
RandomizedDelaySec=30min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
37
scripts/install.sh
Executable file
37
scripts/install.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Запустите установщик от root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
UPDATE_MODE=${UPDATE_MODE:-notify}
|
||||
GITEA_URL=${GITEA_URL:-https://git.myown.center}
|
||||
GITEA_REPOSITORY=${GITEA_REPOSITORY:-maxim/ProxmoxDash}
|
||||
GITEA_TOKEN=${GITEA_TOKEN:-}
|
||||
|
||||
install -d -m 0750 /etc/proxmox-dashboard /var/lib/proxmox-dashboard
|
||||
install -m 0755 "$SOURCE_DIR/dist/proxmox-dashboard-linux-amd64" /usr/local/bin/proxmox-cpu-dashboard
|
||||
install -m 0755 "$SOURCE_DIR/scripts/proxmox-dashboard-update" /usr/local/sbin/proxmox-dashboard-update
|
||||
install -m 0644 "$SOURCE_DIR/proxmox-cpu-dashboard.service" /etc/systemd/system/proxmox-cpu-dashboard.service
|
||||
install -m 0644 "$SOURCE_DIR/proxmox-dashboard-update.service" /etc/systemd/system/proxmox-dashboard-update.service
|
||||
install -m 0644 "$SOURCE_DIR/proxmox-dashboard-update.timer" /etc/systemd/system/proxmox-dashboard-update.timer
|
||||
install -m 0644 "$SOURCE_DIR/proxmox-dashboard-update.path" /etc/systemd/system/proxmox-dashboard-update.path
|
||||
|
||||
umask 077
|
||||
{
|
||||
printf 'GITEA_URL=%s\n' "$GITEA_URL"
|
||||
printf 'GITEA_REPOSITORY=%s\n' "$GITEA_REPOSITORY"
|
||||
printf 'GITEA_TOKEN=%s\n' "$GITEA_TOKEN"
|
||||
printf 'UPDATE_MODE=%s\n' "$UPDATE_MODE"
|
||||
} > /etc/proxmox-dashboard/update.env
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable proxmox-cpu-dashboard.service
|
||||
systemctl restart proxmox-cpu-dashboard.service
|
||||
systemctl enable --now proxmox-dashboard-update.timer
|
||||
systemctl enable --now proxmox-dashboard-update.path
|
||||
systemctl start proxmox-dashboard-update.service
|
||||
echo "Proxmox Dashboard установлен. Откройте http://$(hostname -I | awk '{print $1}'):9105"
|
||||
111
scripts/proxmox-dashboard-update
Executable file
111
scripts/proxmox-dashboard-update
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CONFIG_FILE=${DASHBOARD_UPDATE_CONFIG:-/etc/proxmox-dashboard/update.env}
|
||||
STATUS_FILE=${DASHBOARD_UPDATE_STATUS_PATH:-/var/lib/proxmox-dashboard/update-status.json}
|
||||
BINARY_PATH=${DASHBOARD_BINARY_PATH:-/usr/local/bin/proxmox-cpu-dashboard}
|
||||
REQUEST_FILE=${DASHBOARD_UPDATE_REQUEST_PATH:-/var/lib/proxmox-dashboard/update-request}
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
GITEA_URL=${GITEA_URL:-https://git.myown.center}
|
||||
GITEA_REPOSITORY=${GITEA_REPOSITORY:-maxim/ProxmoxDash}
|
||||
GITEA_TOKEN=${GITEA_TOKEN:-}
|
||||
UPDATE_MODE=${UPDATE_MODE:-notify}
|
||||
ASSET_NAME=${ASSET_NAME:-proxmox-dashboard-linux-amd64}
|
||||
manual_request=false
|
||||
if [ -f "$REQUEST_FILE" ]; then
|
||||
manual_request=true
|
||||
rm -f "$REQUEST_FILE"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$STATUS_FILE")"
|
||||
tmp_dir=$(mktemp -d /tmp/proxmox-dashboard-update.XXXXXX)
|
||||
trap 'rm -rf "$tmp_dir"' EXIT INT TERM
|
||||
|
||||
auth_header=""
|
||||
if [ -n "$GITEA_TOKEN" ]; then
|
||||
auth_header="Authorization: token $GITEA_TOKEN"
|
||||
fi
|
||||
|
||||
api_get() {
|
||||
if [ -n "$auth_header" ]; then
|
||||
curl --fail --silent --show-error --location --header "$auth_header" "$1"
|
||||
else
|
||||
curl --fail --silent --show-error --location "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
write_status() {
|
||||
python3 - "$STATUS_FILE" "$1" "$2" "$3" "$4" "$5" "$6" <<'PY'
|
||||
import datetime, json, os, sys, tempfile
|
||||
path, current, available, state, mode, message, release_url = sys.argv[1:]
|
||||
payload = {
|
||||
"currentVersion": current,
|
||||
"availableVersion": available,
|
||||
"state": state,
|
||||
"mode": mode,
|
||||
"message": message,
|
||||
"releaseUrl": release_url,
|
||||
"checkedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
}
|
||||
fd, temporary = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".update-status-")
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
json.dump(payload, stream)
|
||||
os.chmod(temporary, 0o644)
|
||||
os.replace(temporary, path)
|
||||
PY
|
||||
}
|
||||
|
||||
current_version=$($BINARY_PATH -version 2>/dev/null || printf 'dev')
|
||||
release_json=$(api_get "$GITEA_URL/api/v1/repos/$GITEA_REPOSITORY/releases/latest")
|
||||
printf '%s' "$release_json" > "$tmp_dir/release.json"
|
||||
release_data=$(python3 - "$tmp_dir/release.json" "$ASSET_NAME" <<'PY'
|
||||
import json, sys
|
||||
release = json.load(open(sys.argv[1]))
|
||||
asset_name = sys.argv[2]
|
||||
assets = {item.get("name"): item.get("browser_download_url") for item in release.get("assets", [])}
|
||||
binary = assets.get(asset_name, "")
|
||||
checksum = assets.get(asset_name + ".sha256", "")
|
||||
print(release.get("tag_name", ""))
|
||||
print(release.get("html_url", ""))
|
||||
print(binary)
|
||||
print(checksum)
|
||||
PY
|
||||
)
|
||||
latest_version=$(printf '%s\n' "$release_data" | sed -n '1p')
|
||||
release_url=$(printf '%s\n' "$release_data" | sed -n '2p')
|
||||
binary_url=$(printf '%s\n' "$release_data" | sed -n '3p')
|
||||
checksum_url=$(printf '%s\n' "$release_data" | sed -n '4p')
|
||||
|
||||
if [ -z "$latest_version" ] || [ -z "$binary_url" ]; then
|
||||
write_status "$current_version" "$latest_version" error "$UPDATE_MODE" "В свежем релизе не найден бинарник $ASSET_NAME." "$release_url"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
newest=$(printf '%s\n%s\n' "$current_version" "$latest_version" | sort -V | tail -n 1)
|
||||
if [ "$current_version" = "$latest_version" ] || [ "$newest" = "$current_version" ]; then
|
||||
write_status "$current_version" "$latest_version" current "$UPDATE_MODE" "Установлена свежая версия." "$release_url"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$UPDATE_MODE" != "auto" ] && [ "$manual_request" != "true" ]; then
|
||||
write_status "$current_version" "$latest_version" available "$UPDATE_MODE" "Доступна новая версия $latest_version." "$release_url"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
write_status "$current_version" "$latest_version" installing "$UPDATE_MODE" "Скачиваем и проверяем $latest_version." "$release_url"
|
||||
api_get "$binary_url" > "$tmp_dir/$ASSET_NAME"
|
||||
if [ -n "$checksum_url" ]; then
|
||||
api_get "$checksum_url" > "$tmp_dir/$ASSET_NAME.sha256"
|
||||
(cd "$tmp_dir" && sha256sum -c "$ASSET_NAME.sha256")
|
||||
fi
|
||||
chmod 0755 "$tmp_dir/$ASSET_NAME"
|
||||
file "$tmp_dir/$ASSET_NAME" | grep -q 'ELF 64-bit'
|
||||
cp -p "$BINARY_PATH" "$BINARY_PATH.previous"
|
||||
install -m 0755 "$tmp_dir/$ASSET_NAME" "$BINARY_PATH"
|
||||
write_status "$latest_version" "$latest_version" updated "$UPDATE_MODE" "Обновление $latest_version установлено." "$release_url"
|
||||
systemctl restart proxmox-cpu-dashboard.service
|
||||
65
store.go
65
store.go
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -59,6 +60,21 @@ type HistoryPoint struct {
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
type EmailSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
SkipTLSVerify bool `json:"skipTlsVerify"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
IntervalMinutes int `json:"intervalMinutes"`
|
||||
Severities []string `json:"severities"`
|
||||
Sources []string `json:"sources"`
|
||||
}
|
||||
|
||||
func openStore(path string) (*Store, error) {
|
||||
if path != ":memory:" {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
@@ -114,6 +130,55 @@ func (s *Store) SaveThresholds(value AlertThresholds) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func defaultEmailSettings() EmailSettings {
|
||||
return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Dashboard"}}
|
||||
}
|
||||
|
||||
func (s *Store) EmailSettings(includePassword bool) EmailSettings {
|
||||
settings := defaultEmailSettings()
|
||||
var value string
|
||||
if s.db.QueryRow(`SELECT value FROM settings WHERE key='email_notifications'`).Scan(&value) == nil {
|
||||
_ = json.Unmarshal([]byte(value), &settings)
|
||||
}
|
||||
settings.HasPassword = settings.Password != ""
|
||||
if !includePassword {
|
||||
settings.Password = ""
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func (s *Store) SaveEmailSettings(settings EmailSettings) error {
|
||||
if settings.Port < 1 || settings.Port > 65535 || settings.IntervalMinutes < 5 || settings.IntervalMinutes > 10080 {
|
||||
return fmt.Errorf("проверьте порт и интервал (от 5 минут до 7 дней)")
|
||||
}
|
||||
if settings.Enabled && (settings.Host == "" || settings.From == "" || settings.To == "") {
|
||||
return fmt.Errorf("для отправки нужны SMTP-сервер, отправитель и получатель")
|
||||
}
|
||||
if strings.ContainsAny(settings.Host+settings.From+settings.To+settings.Username, "\r\n") {
|
||||
return fmt.Errorf("поля почты содержат недопустимые символы")
|
||||
}
|
||||
if settings.Password == "" {
|
||||
settings.Password = s.EmailSettings(true).Password
|
||||
}
|
||||
settings.HasPassword = settings.Password != ""
|
||||
data, _ := json.Marshal(settings)
|
||||
_, err := s.db.Exec(`INSERT INTO settings(key,value) VALUES('email_notifications',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) LastEmailAt() time.Time {
|
||||
var value string
|
||||
if s.db.QueryRow(`SELECT value FROM settings WHERE key='email_last_sent'`).Scan(&value) != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
result, _ := time.Parse(time.RFC3339, value)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Store) SetLastEmailAt(value time.Time) {
|
||||
_, _ = s.db.Exec(`INSERT INTO settings(key,value) VALUES('email_last_sent',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, value.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func validateThresholds(t AlertThresholds) error {
|
||||
percentages := []float64{t.CPUWarning, t.CPUCritical, t.MemoryWarning, t.MemoryCritical, t.SwapWarning, t.StorageWarning, t.StorageCritical, t.ZFSWarning, t.ZFSCritical, t.UPSChargeCritical}
|
||||
for _, value := range percentages {
|
||||
|
||||
77
update.go
Normal file
77
update.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultUpdateStatusPath = "/var/lib/proxmox-dashboard/update-status.json"
|
||||
const defaultUpdateRequestPath = "/var/lib/proxmox-dashboard/update-request"
|
||||
|
||||
type UpdateStatus struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
AvailableVersion string `json:"availableVersion"`
|
||||
State string `json:"state"`
|
||||
Mode string `json:"mode"`
|
||||
Message string `json:"message"`
|
||||
ReleaseURL string `json:"releaseUrl"`
|
||||
CheckedAt time.Time `json:"checkedAt"`
|
||||
}
|
||||
|
||||
func readUpdateStatus() UpdateStatus {
|
||||
status := UpdateStatus{CurrentVersion: version, State: "unknown", Mode: "notify"}
|
||||
path := os.Getenv("DASHBOARD_UPDATE_STATUS_PATH")
|
||||
if path == "" {
|
||||
path = defaultUpdateStatusPath
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || json.Unmarshal(data, &status) != nil {
|
||||
return status
|
||||
}
|
||||
status.CurrentVersion = version
|
||||
return status
|
||||
}
|
||||
|
||||
func requestUpdate() (UpdateStatus, error) {
|
||||
requestPath := os.Getenv("DASHBOARD_UPDATE_REQUEST_PATH")
|
||||
if requestPath == "" {
|
||||
requestPath = defaultUpdateRequestPath
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(requestPath), 0o750); err != nil {
|
||||
return UpdateStatus{}, err
|
||||
}
|
||||
status := readUpdateStatus()
|
||||
status.State = "queued"
|
||||
status.Message = "Обновление поставлено в очередь."
|
||||
status.CheckedAt = time.Now()
|
||||
statusPath := os.Getenv("DASHBOARD_UPDATE_STATUS_PATH")
|
||||
if statusPath == "" {
|
||||
statusPath = defaultUpdateStatusPath
|
||||
}
|
||||
data, _ := json.Marshal(status)
|
||||
temporary, err := os.CreateTemp(filepath.Dir(statusPath), ".update-status-")
|
||||
if err != nil {
|
||||
return UpdateStatus{}, err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if _, err = temporary.Write(data); err == nil {
|
||||
err = temporary.Chmod(0o644)
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Rename(temporaryPath, statusPath)
|
||||
}
|
||||
if err != nil {
|
||||
return UpdateStatus{}, fmt.Errorf("не удалось сохранить запрос обновления: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(requestPath, []byte(time.Now().Format(time.RFC3339Nano)), 0o640); err != nil {
|
||||
return UpdateStatus{}, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
45
update_test.go
Normal file
45
update_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadUpdateStatus(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "status.json")
|
||||
if err := os.WriteFile(path, []byte(`{"currentVersion":"old","availableVersion":"v2.0.0","state":"available","mode":"notify","message":"Доступно обновление"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DASHBOARD_UPDATE_STATUS_PATH", path)
|
||||
status := readUpdateStatus()
|
||||
if status.CurrentVersion != version || status.AvailableVersion != "v2.0.0" || status.State != "available" {
|
||||
t.Fatalf("unexpected update status: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUpdateStatusWithoutFile(t *testing.T) {
|
||||
t.Setenv("DASHBOARD_UPDATE_STATUS_PATH", filepath.Join(t.TempDir(), "missing.json"))
|
||||
status := readUpdateStatus()
|
||||
if status.CurrentVersion != version || status.State != "unknown" {
|
||||
t.Fatalf("unexpected default update status: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestUpdate(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
statusPath := filepath.Join(directory, "status.json")
|
||||
requestPath := filepath.Join(directory, "request")
|
||||
t.Setenv("DASHBOARD_UPDATE_STATUS_PATH", statusPath)
|
||||
t.Setenv("DASHBOARD_UPDATE_REQUEST_PATH", requestPath)
|
||||
status, err := requestUpdate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.State != "queued" {
|
||||
t.Fatalf("unexpected queued status: %+v", status)
|
||||
}
|
||||
if _, err := os.Stat(requestPath); err != nil {
|
||||
t.Fatalf("update request was not created: %v", err)
|
||||
}
|
||||
}
|
||||
92
web.go
92
web.go
@@ -20,6 +20,7 @@ type dashboardMetrics struct {
|
||||
Guests GuestsMetrics `json:"guests"`
|
||||
Backups BackupMetrics `json:"backups"`
|
||||
Alerts []Alert `json:"alerts"`
|
||||
Update UpdateStatus `json:"update"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
|
||||
CPU: cpu, Memory: memory, Storage: storage,
|
||||
Disks: diskCollector.collect(), Network: network,
|
||||
ZFS: zfsCollector.collect(), UPS: upsCollector.collect(),
|
||||
Guests: guestCollector.collect(), Backups: backupCollector.collect(), Timestamp: time.Now(),
|
||||
Guests: guestCollector.collect(), Backups: backupCollector.collect(), Update: readUpdateStatus(), Timestamp: time.Now(),
|
||||
}
|
||||
metrics.Alerts = evaluateAlerts(metrics, store.Thresholds())
|
||||
return metrics, nil
|
||||
@@ -62,6 +63,7 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
|
||||
return collectDashboard(collector, network, disks, zfs, ups, guests, backups, store)
|
||||
}
|
||||
go runHistoryCollector(collect, store)
|
||||
go runEmailNotifier(collect, store)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
@@ -75,6 +77,15 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
|
||||
writeJSON(w, metrics, err)
|
||||
})
|
||||
mux.HandleFunc("/api/version", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, map[string]string{"version": version}, nil) })
|
||||
mux.HandleFunc("/api/update/status", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, readUpdateStatus(), nil) })
|
||||
mux.HandleFunc("/api/update/request", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
status, err := requestUpdate()
|
||||
writeJSON(w, status, err)
|
||||
})
|
||||
mux.HandleFunc("/api/system", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if networkErr != nil {
|
||||
writeJSON(w, nil, networkErr)
|
||||
@@ -102,6 +113,33 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/settings/email", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, store.EmailSettings(false), nil)
|
||||
case http.MethodPut:
|
||||
var settings EmailSettings
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&settings); err != nil {
|
||||
http.Error(w, "Некорректные настройки", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.SaveEmailSettings(settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, store.EmailSettings(false), nil)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/settings/email/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
settings := store.EmailSettings(true)
|
||||
writeJSON(w, map[string]bool{"sent": true}, sendAlertEmail(settings, nil, true))
|
||||
})
|
||||
mux.HandleFunc("/api/history", func(w http.ResponseWriter, r *http.Request) {
|
||||
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
|
||||
points, err := store.History(hours)
|
||||
@@ -171,11 +209,12 @@ dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding:
|
||||
.modal-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;padding:26px 28px;background:rgba(16,24,32,.94);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}.modal-head h2{font-size:24px;letter-spacing:-.03em;margin:5px 0 0}.close{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:#17222b;color:var(--text);font-size:22px;cursor:pointer}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr)}.detail{padding:22px 27px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.detail:nth-child(3n){border-right:0}.detail strong{display:block;font:650 24px/1.2 ui-monospace,monospace;margin-top:7px}.wide{padding:24px 28px}.temps{display:flex;flex-wrap:wrap;gap:9px;margin-top:12px}.temp{padding:7px 11px;border:1px solid var(--line);border-radius:99px}.temp b{color:var(--green);margin-left:7px}.empty{color:var(--muted);font-size:13px}
|
||||
.disk-list{display:grid;gap:12px;padding:16px}.disk-item{padding:19px;border:1px solid var(--line);border-radius:15px;background:#0c141a}.disk-top{display:flex;justify-content:space-between;gap:20px}.disk-model{font-size:17px;font-weight:650}.disk-meta{color:var(--muted);font:12px/1.5 ui-monospace,monospace;margin-top:4px;overflow-wrap:anywhere}.badge{position:relative;height:max-content;padding:5px 9px;border:1px solid #2d6245;border-radius:99px;color:var(--green);font-size:11px;white-space:nowrap}.badge.warn{color:#ffb35c;border-color:#6d4c2c}.badge.bad{color:#ff755c;border-color:#71362f}.badge[data-reason]:hover::after,.badge[data-reason]:focus::after{content:attr(data-reason);position:absolute;z-index:5;right:0;bottom:calc(100% + 10px);width:280px;padding:11px 13px;border:1px solid #4c5963;border-radius:10px;background:#1a242c;color:var(--text);font:12px/1.45 Inter,system-ui,sans-serif;white-space:normal;box-shadow:0 14px 35px #0008}.disk-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-top:18px}.disk-stat span{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.disk-stat b{display:block;margin-top:5px;font:600 14px ui-monospace,monospace}
|
||||
.settings-tabs{display:flex;gap:6px;padding:12px 26px 0}.settings-tab{padding:9px 13px;border:1px solid transparent;border-radius:10px;background:transparent;color:var(--muted);cursor:pointer}.settings-tab.active{background:var(--panel);border-color:var(--line);color:var(--text)}.settings-body{padding:22px 26px 28px}.settings-panel[hidden]{display:none}.settings-group+.settings-group{margin-top:25px}.theme-options{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:11px}.theme-option{padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--text);cursor:pointer}.theme-option.active{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green)}.setting-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:11px}.setting-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.setting-item input{accent-color:var(--green);width:17px;height:17px}.threshold-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.threshold-item{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.threshold-item span{font-size:13px}.threshold-item input{width:78px;padding:7px 8px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--text);font:600 14px ui-monospace,monospace}.save-settings{width:100%;margin-top:16px;padding:12px;border:0;border-radius:11px;background:var(--green);color:#07120c;font-weight:700;cursor:pointer}.settings-note{color:var(--muted);font-size:12px;margin-top:10px}.card[hidden]{display:none}
|
||||
.chart-section{padding:22px 25px;border-top:1px solid var(--line)}.chart-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.chart-head span{color:var(--muted);font-size:12px}.metric-chart{display:block;width:100%;height:180px;border-radius:12px;background:rgba(100,130,150,.05)}
|
||||
.detail-tabs{display:flex;gap:6px;padding:12px 26px;border-bottom:1px solid var(--line)}.detail-tab{padding:9px 13px;border:1px solid transparent;border-radius:10px;background:transparent;color:var(--muted);cursor:pointer}.detail-tab.active{background:var(--panel);border-color:var(--line);color:var(--text)}.detail-panel[hidden]{display:none}.chart-section{position:relative;padding:22px 25px}.chart-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:12px}.range-switch{display:flex;gap:4px;padding:3px;border:1px solid var(--line);border-radius:10px}.range-button{padding:5px 9px;border:0;border-radius:7px;background:transparent;color:var(--muted);font-size:11px;cursor:pointer}.range-button.active{background:var(--panel);color:var(--text);box-shadow:0 1px 5px #0003}.metric-chart{display:block;width:100%;height:200px;border-radius:12px;background:rgba(100,130,150,.05);cursor:crosshair}.chart-tooltip{display:none;position:absolute;z-index:4;min-width:165px;padding:10px 12px;border:1px solid #40505c;border-radius:10px;background:#17222b;color:var(--text);font-size:12px;line-height:1.55;pointer-events:none;box-shadow:0 12px 32px #0008}.chart-tooltip.visible{display:block}.chart-tooltip-time{color:var(--muted);margin-bottom:4px}.chart-tooltip-value{display:flex;align-items:center;justify-content:space-between;gap:18px}.chart-tooltip-value i{width:7px;height:7px;border-radius:50%;margin-right:7px;display:inline-block}
|
||||
.alert-list{display:grid;gap:10px;padding:16px}.alert-item{display:grid;grid-template-columns:auto 1fr;gap:13px;padding:17px 18px;border:1px solid var(--line);border-radius:14px;background:#0c141a}.alert-icon{width:10px;height:10px;border-radius:50%;margin-top:6px;background:#6ba8ff;box-shadow:0 0 14px currentColor}.alert-item.warning .alert-icon{background:#ffb35c}.alert-item.critical .alert-icon{background:#ff665c}.alert-title{font-weight:650}.alert-source{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.alert-message{color:var(--muted);font-size:13px;margin-top:4px}.all-good{padding:45px 24px;text-align:center}.all-good strong{display:block;font-size:22px;margin-bottom:7px}.all-good span{color:var(--muted)}
|
||||
html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9da 0,transparent 34%),var(--bg)}html[data-theme="light"] .card{background:linear-gradient(145deg,#fff,#f7f9fa)}html[data-theme="light"] dialog{background:#fff;box-shadow:0 30px 80px #4e64752b}html[data-theme="light"] .modal-head{background:rgba(255,255,255,.94)}html[data-theme="light"] .close{background:#f1f4f6}html[data-theme="light"] .disk-item,html[data-theme="light"] .alert-item{background:#f7f9fa}html[data-theme="light"] .bar{background:#e2e8ec}html[data-theme="light"] dialog::backdrop{background:rgba(30,45,55,.35)}html[data-theme="light"] .badge[data-reason]:hover::after,html[data-theme="light"] .badge[data-reason]:focus::after{background:#fff}
|
||||
html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9da 0,transparent 34%),var(--bg)}html[data-theme="light"] .card{background:linear-gradient(145deg,#fff,#f7f9fa)}html[data-theme="light"] dialog{background:#fff;box-shadow:0 30px 80px #4e64752b}html[data-theme="light"] .modal-head{background:rgba(255,255,255,.94)}html[data-theme="light"] .close{background:#f1f4f6}html[data-theme="light"] .disk-item,html[data-theme="light"] .alert-item{background:#f7f9fa}html[data-theme="light"] .bar{background:#e2e8ec}html[data-theme="light"] dialog::backdrop{background:rgba(30,45,55,.35)}html[data-theme="light"] .badge[data-reason]:hover::after,html[data-theme="light"] .badge[data-reason]:focus::after,html[data-theme="light"] .chart-tooltip{background:#fff;box-shadow:0 12px 32px #4e647533}
|
||||
footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
@media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats,.setting-list,.threshold-grid{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats,.setting-list,.threshold-grid{grid-template-columns:repeat(2,1fr)}.chart-head{align-items:flex-start;flex-direction:column}.detail-tabs{overflow-x:auto}}
|
||||
.save-settings:disabled{cursor:wait;opacity:.55}[data-settings-panel="email"] .threshold-item input{width:min(210px,52vw)}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
@@ -314,7 +353,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
|
||||
<dialog id="settingsDialog">
|
||||
<div class="modal-head"><div><div class="label">Dashboard</div><h2>Настройки</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||||
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Уведомления</button></div>
|
||||
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="appearance">
|
||||
<div class="settings-group"><div class="label">Тема оформления</div><div class="theme-options"><button class="theme-option" data-theme-choice="dark">Тёмная</button><button class="theme-option" data-theme-choice="light">Светлая</button></div></div>
|
||||
<div class="settings-group"><div class="label">Видимые карточки</div><div class="setting-list">
|
||||
@@ -338,6 +377,26 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
</div>
|
||||
<button class="save-settings" id="saveThresholds">Сохранить пороги</button><div class="settings-note" id="thresholdStatus">Настройки хранятся на сервере и применяются для всех браузеров.</div>
|
||||
</div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="email" hidden>
|
||||
<div class="settings-group"><div class="setting-list"><label class="setting-item"><input type="checkbox" id="emailEnabled"> Включить почтовые дайджесты</label><label class="setting-item"><input type="checkbox" id="emailSkipTLS"> Не проверять TLS-сертификат</label></div><div class="settings-note">Только для доверенного сервера во внутренней сети.</div></div>
|
||||
<div class="settings-group"><div class="label">SMTP и получатель</div><div class="threshold-grid">
|
||||
<label class="threshold-item"><span>SMTP-сервер</span><input id="emailHost" type="text"></label><label class="threshold-item"><span>Порт</span><input id="emailPort" type="number"></label>
|
||||
<label class="threshold-item"><span>Логин</span><input id="emailUsername" type="text"></label><label class="threshold-item"><span>Пароль</span><input id="emailPassword" type="password" placeholder="Не изменять"></label>
|
||||
<label class="threshold-item"><span>Отправитель</span><input id="emailFrom" type="email"></label><label class="threshold-item"><span>Получатель</span><input id="emailTo" type="email"></label>
|
||||
<label class="threshold-item"><span>Интервал, минут</span><input id="emailInterval" type="number" min="5"></label>
|
||||
</div></div>
|
||||
<div class="settings-group"><div class="label">Важность</div><div class="setting-list"><label class="setting-item"><input type="checkbox" data-email-severity="critical"> Критические</label><label class="setting-item"><input type="checkbox" data-email-severity="warning"> Предупреждения</label><label class="setting-item"><input type="checkbox" data-email-severity="info"> Информация</label></div></div>
|
||||
<div class="settings-group"><div class="label">Какие разделы отправлять</div><div class="setting-list">
|
||||
<label class="setting-item"><input type="checkbox" data-email-source="CPU"> CPU</label><label class="setting-item"><input type="checkbox" data-email-source="Память"> Память</label><label class="setting-item"><input type="checkbox" data-email-source="Storage"> Storage</label><label class="setting-item"><input type="checkbox" data-email-source="Диски"> Диски</label><label class="setting-item"><input type="checkbox" data-email-source="ZFS"> ZFS</label><label class="setting-item"><input type="checkbox" data-email-source="UPS"> UPS</label><label class="setting-item"><input type="checkbox" data-email-source="VM/LXC"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-email-source="Backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-email-source="Dashboard"> Dashboard</label>
|
||||
</div></div>
|
||||
<button class="save-settings" id="saveEmail">Сохранить настройки почты</button><button class="theme-option" id="testEmail" style="width:100%;margin-top:9px">Отправить тестовое письмо</button><div class="settings-note" id="emailStatus">Пароль хранится в локальной базе сервера и не возвращается в браузер.</div>
|
||||
</div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="updates" hidden>
|
||||
<div class="detail-grid">
|
||||
<div class="detail"><span class="label">Установлена</span><strong id="updateCurrent">—</strong></div><div class="detail"><span class="label">Доступна</span><strong id="updateAvailable">—</strong></div><div class="detail"><span class="label">Режим</span><strong id="updateMode">—</strong></div>
|
||||
</div>
|
||||
<div class="wide"><div class="label">Состояние обновлений</div><div class="temps"><span class="temp" id="updateMessage">Проверка ещё не выполнялась</span></div><button class="save-settings" id="installUpdate">Проверить и установить обновление</button><div class="settings-note" id="updateChecked">Проверка запускается автоматически раз в сутки.</div></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
@@ -348,6 +407,7 @@ const safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'
|
||||
const uptime=s=>{const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?d+'д '+h+'ч':h?h+'ч '+m+'м':m+'м'};
|
||||
let alertSettings={backupMaxAgeHours:48};
|
||||
function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
|
||||
const update=x.update||{},updating=['queued','installing'].includes(update.state);el('updateCurrent').textContent=update.currentVersion||'—';el('updateAvailable').textContent=update.availableVersion||'—';el('updateMode').textContent=update.mode==='auto'?'Автоматически':'Уведомлять';el('updateMessage').textContent=update.message||'Проверка ещё не выполнялась';el('updateChecked').textContent=update.checkedAt?'Последняя проверка: '+new Date(update.checkedAt).toLocaleString('ru-RU'):'Проверка запускается автоматически раз в сутки.';el('installUpdate').disabled=updating;el('installUpdate').textContent=updating?'Обновление выполняется…':'Проверить и установить обновление';
|
||||
const critical=alerts.filter(a=>a.severity==='critical').length,warnings=alerts.filter(a=>a.severity==='warning').length,infos=alerts.filter(a=>a.severity==='info').length;el('alertCount').textContent=alerts.length;el('alertCritical').textContent=critical;el('alertWarnings').textContent=warnings;el('alertHeadline').textContent=critical?'Требуется срочное внимание':warnings?'Есть предупреждения':infos?'Есть информационные события':'Система в норме';el('alertBar').style.width=(critical?100:warnings?65:infos?30:0)+'%';
|
||||
el('alertList').innerHTML=alerts.length?alerts.map(a=>'<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div></div></article>').join(''):'<div class="all-good"><strong>Всё в порядке</strong><span>Активных предупреждений сейчас нет.</span></div>';
|
||||
el('cpuName').textContent=el('cpuDetailName').textContent=c.model;el('cpuUsage').textContent=fixed(c.usagePercent);el('cpuBar').style.width=Math.min(c.usagePercent,100)+'%';el('cpuTemp').textContent=avg===null?'Нет датчика':fixed(avg)+' °C';el('cpuTopology').textContent=c.physicalCores+' / '+c.logicalCpus;
|
||||
@@ -366,18 +426,28 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av
|
||||
el('backupList').innerHTML=guests.length?guests.map(v=>{const item=backupMap.get(v.vmid),type=v.type==='qemu'?'VM':'LXC';if(!item)return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+'</div></div><span class="badge bad">Копии нет</span></div></article>';const age=now-item.ctime,fresh=age<=backupLimit,date=new Date(item.ctime*1000).toLocaleString('ru-RU');return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+date+'</div></div><span class="badge '+(fresh?'':'warn')+'">'+(fresh?'Актуальна':'Старше '+backupLimitHours+' ч')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Хранилище</span><b>'+safe(item.storage)+'</b></div><div class="disk-stat"><span>Размер</span><b>'+size(item.size)+'</b></div><div class="disk-stat"><span>Формат</span><b>'+safe(item.format)+'</b></div><div class="disk-stat"><span>Возраст</span><b>'+uptime(age)+'</b></div><div class="disk-stat"><span>Создана</span><b>'+date+'</b></div><div class="disk-stat"><span>VMID</span><b>'+v.vmid+'</b></div></div></article>'}).join(''):'<span class="empty">'+(b.available?'VM и LXC не найдены':'Данные резервных копий недоступны')+'</span>';
|
||||
el('dot').classList.add('live');el('status').textContent='Данные поступают';
|
||||
}
|
||||
let historyData=null,historyLoadedAt=0;
|
||||
async function loadHistory(){if(historyData&&Date.now()-historyLoadedAt<60000)return historyData;const response=await fetch('/api/history?hours=168');if(!response.ok)throw new Error('history');historyData=await response.json()||[];historyLoadedAt=Date.now();return historyData}
|
||||
function drawChart(id,series,fixedMax=100){const canvas=el(id);if(!canvas||!canvas.offsetWidth)return;const points=historyData||[],dpr=window.devicePixelRatio||1,w=canvas.offsetWidth,h=180;canvas.width=w*dpr;canvas.height=h*dpr;const ctx=canvas.getContext('2d');ctx.scale(dpr,dpr);const styles=getComputedStyle(document.documentElement),text=styles.getPropertyValue('--muted').trim(),line=styles.getPropertyValue('--line').trim();ctx.clearRect(0,0,w,h);ctx.font='11px system-ui';ctx.fillStyle=text;if(points.length<2){ctx.textAlign='center';ctx.fillText('История появится после нескольких минут работы',w/2,h/2);return}const values=series.flatMap(s=>points.map(p=>s.value(p)).filter(v=>v!=null&&Number.isFinite(v))),max=fixedMax||Math.max(...values,1)*1.1,min=0,pad={l:38,r:12,t:30,b:22},pw=w-pad.l-pad.r,ph=h-pad.t-pad.b;ctx.strokeStyle=line;ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pad.t+ph*i/4;ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(w-pad.r,y);ctx.stroke();ctx.fillStyle=text;ctx.textAlign='right';ctx.fillText((max*(1-i/4)).toFixed(max>100?0:0),pad.l-7,y+4)}series.forEach((s,index)=>{ctx.strokeStyle=s.color;ctx.lineWidth=2;ctx.beginPath();let started=false;points.forEach((p,i)=>{const v=s.value(p);if(v==null||!Number.isFinite(v))return;const x=pad.l+pw*i/(points.length-1),y=pad.t+ph*(1-(v-min)/(max-min||1));if(!started){ctx.moveTo(x,y);started=true}else ctx.lineTo(x,y)});ctx.stroke();ctx.fillStyle=s.color;ctx.textAlign='left';ctx.fillText(s.label,pad.l+index*125,15)});ctx.fillStyle=text;ctx.textAlign='left';ctx.fillText('7 дней назад',pad.l,h-5);ctx.textAlign='right';ctx.fillText('сейчас',w-pad.r,h-5)}
|
||||
function drawAllCharts(){drawChart('cpuChart',[{label:'CPU %',color:'#55d58a',value:p=>p.cpuUsage},{label:'Температура °C',color:'#ff9b5c',value:p=>p.cpuTemperature}]);drawChart('memoryChart',[{label:'RAM %',color:'#9d8cff',value:p=>p.memoryUsage},{label:'Swap %',color:'#ff78b8',value:p=>p.swapUsage}]);drawChart('storageChart',[{label:'Раздел / %',color:'#ff9b5c',value:p=>p.rootUsage}]);drawChart('networkChart',[{label:'Приём MB/s',color:'#43c6d9',value:p=>p.networkReceive/1048576},{label:'Передача MB/s',color:'#65ddaa',value:p=>p.networkTransmit/1048576}],0);drawChart('zfsChart',[{label:'ZFS %',color:'#6577ff',value:p=>p.zfsUsage}]);drawChart('upsChart',[{label:'Заряд %',color:'#55d58a',value:p=>p.upsCharge},{label:'Нагрузка %',color:'#d5e85a',value:p=>p.upsLoad}]);drawChart('guestsChart',[{label:'CPU %',color:'#ff78b8',value:p=>p.guestsCpu},{label:'RAM %',color:'#9d8cff',value:p=>p.guestsMemory}])}
|
||||
document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>{const dialog=el(b.dataset.open);dialog.showModal();if(dialog.querySelector('canvas'))setTimeout(()=>loadHistory().then(drawAllCharts).catch(()=>{}),50)});
|
||||
const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed(v)+' MB/s';
|
||||
const chartConfigs={cpuChart:{series:[{label:'CPU',color:'#55d58a',value:p=>p.cpuUsage,format:percent},{label:'Температура',color:'#ff9b5c',value:p=>p.cpuTemperature,format:temperature}]},memoryChart:{series:[{label:'RAM',color:'#9d8cff',value:p=>p.memoryUsage,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swapUsage,format:percent}]},storageChart:{series:[{label:'Раздел /',color:'#ff9b5c',value:p=>p.rootUsage,format:percent}]},networkChart:{max:0,series:[{label:'Приём',color:'#43c6d9',value:p=>p.networkReceive/1048576,format:megabytes},{label:'Передача',color:'#65ddaa',value:p=>p.networkTransmit/1048576,format:megabytes}]},zfsChart:{series:[{label:'ZFS',color:'#6577ff',value:p=>p.zfsUsage,format:percent}]},upsChart:{series:[{label:'Заряд',color:'#55d58a',value:p=>p.upsCharge,format:percent},{label:'Нагрузка',color:'#d5e85a',value:p=>p.upsLoad,format:percent}]},guestsChart:{series:[{label:'CPU',color:'#ff78b8',value:p=>p.guestsCpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.guestsMemory,format:percent}]}};
|
||||
const historyCache=new Map();
|
||||
async function loadHistory(hours){const cached=historyCache.get(hours);if(cached&&Date.now()-cached.loadedAt<60000)return cached.points;const response=await fetch('/api/history?hours='+hours);if(!response.ok)throw new Error('history');const points=await response.json()||[];historyCache.set(hours,{points,loadedAt:Date.now()});return points}
|
||||
function drawChart(id,points){const canvas=el(id),config=chartConfigs[id];if(!canvas||!config||!canvas.offsetWidth)return;const series=config.series,dpr=window.devicePixelRatio||1,w=canvas.offsetWidth,h=200;canvas.width=w*dpr;canvas.height=h*dpr;const ctx=canvas.getContext('2d');ctx.scale(dpr,dpr);const styles=getComputedStyle(document.documentElement),text=styles.getPropertyValue('--muted').trim(),line=styles.getPropertyValue('--line').trim();ctx.clearRect(0,0,w,h);ctx.font='11px system-ui';ctx.fillStyle=text;if(points.length<2){ctx.textAlign='center';ctx.fillText('История появится после нескольких минут работы',w/2,h/2);canvas._chartState=null;return}const values=series.flatMap(s=>points.map(p=>s.value(p)).filter(v=>v!=null&&Number.isFinite(v))),max=config.max===0?Math.max(...values,1)*1.1:100,pad={l:38,r:12,t:30,b:25},pw=w-pad.l-pad.r,ph=h-pad.t-pad.b;ctx.strokeStyle=line;ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=pad.t+ph*i/4;ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(w-pad.r,y);ctx.stroke();ctx.fillStyle=text;ctx.textAlign='right';ctx.fillText((max*(1-i/4)).toFixed(max<10?1:0),pad.l-7,y+4)}series.forEach((s,index)=>{ctx.strokeStyle=s.color;ctx.lineWidth=2;ctx.beginPath();let started=false;points.forEach((p,i)=>{const v=s.value(p);if(v==null||!Number.isFinite(v))return;const x=pad.l+pw*i/(points.length-1),y=pad.t+ph*(1-v/(max||1));if(!started){ctx.moveTo(x,y);started=true}else ctx.lineTo(x,y)});ctx.stroke();ctx.fillStyle=s.color;ctx.textAlign='left';ctx.fillText(s.label,pad.l+index*130,15)});const first=new Date(points[0].timestamp*1000),last=new Date(points[points.length-1].timestamp*1000),timeOptions={hour:'2-digit',minute:'2-digit'};ctx.fillStyle=text;ctx.textAlign='left';ctx.fillText(first.toLocaleString('ru-RU',timeOptions),pad.l,h-6);ctx.textAlign='right';ctx.fillText(last.toLocaleString('ru-RU',timeOptions),w-pad.r,h-6);canvas._chartState={points,series,pad,pw,w}}
|
||||
function setupChartHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,section=canvas.closest('.chart-section'),tooltip=section&§ion.querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index],date=new Date(point.timestamp*1000);tooltip.innerHTML='<div class="chart-tooltip-time">'+date.toLocaleString('ru-RU')+'</div>'+state.series.map(s=>{const value=s.value(point),shown=value==null||!Number.isFinite(value)?'—':s.format(value);return '<div class="chart-tooltip-value"><span><i style="background:'+s.color+'"></i>'+s.label+'</span><strong>'+shown+'</strong></div>'}).join('');tooltip.classList.add('visible');const sectionRect=section.getBoundingClientRect(),left=Math.min(event.clientX-sectionRect.left+12,section.clientWidth-tooltip.offsetWidth-10);tooltip.style.left=Math.max(10,left)+'px';tooltip.style.top=Math.max(55,event.clientY-sectionRect.top-tooltip.offsetHeight-12)+'px'});canvas.addEventListener('mouseleave',()=>{const tooltip=canvas.closest('.chart-section').querySelector('.chart-tooltip');tooltip.classList.remove('visible')})}
|
||||
async function showHistory(section){const hours=Number(section.dataset.hours||1),canvas=section.querySelector('canvas');try{drawChart(canvas.id,await loadHistory(hours))}catch(e){drawChart(canvas.id,[])}}
|
||||
function setupDetailTabs(){document.querySelectorAll('dialog').forEach(dialog=>{const chart=dialog.querySelector('.chart-section');if(!chart)return;const head=dialog.querySelector('.modal-head'),tabs=document.createElement('div'),realtime=document.createElement('div');tabs.className='detail-tabs';tabs.innerHTML='<button class="detail-tab active" data-detail-tab="realtime">Реальное время</button><button class="detail-tab" data-detail-tab="history">История</button>';realtime.className='detail-panel';realtime.dataset.detailPanel='realtime';while(head.nextElementSibling&&head.nextElementSibling!==chart)realtime.appendChild(head.nextElementSibling);head.after(tabs);tabs.after(realtime);chart.classList.add('detail-panel');chart.dataset.detailPanel='history';chart.dataset.hours='1';chart.hidden=true;const title=chart.querySelector('.chart-head strong'),chartHead=chart.querySelector('.chart-head');chartHead.innerHTML='';chartHead.appendChild(title);const ranges=document.createElement('div');ranges.className='range-switch';ranges.innerHTML='<button class="range-button active" data-hours="1">1 час</button><button class="range-button" data-hours="24">24 часа</button><button class="range-button" data-hours="168">7 дней</button>';chartHead.appendChild(ranges);const tooltip=document.createElement('div');tooltip.className='chart-tooltip';chart.appendChild(tooltip);setupChartHover(chart.querySelector('canvas'));tabs.querySelectorAll('[data-detail-tab]').forEach(tab=>tab.onclick=()=>{tabs.querySelectorAll('.detail-tab').forEach(t=>t.classList.toggle('active',t===tab));realtime.hidden=tab.dataset.detailTab!=='realtime';chart.hidden=tab.dataset.detailTab!=='history';if(!chart.hidden)setTimeout(()=>showHistory(chart),20)});ranges.querySelectorAll('[data-hours]').forEach(button=>button.onclick=()=>{chart.dataset.hours=button.dataset.hours;ranges.querySelectorAll('.range-button').forEach(b=>b.classList.toggle('active',b===button));showHistory(chart)})})}
|
||||
setupDetailTabs();
|
||||
document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>el(b.dataset.open).showModal());
|
||||
document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclick=()=>d.close();d.onclick=e=>{if(e.target===d)d.close()}});
|
||||
const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','guests','backups'];let hiddenCards=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]')}catch(e){}
|
||||
function applyCardSettings(){cardNames.forEach(name=>{const visible=!hiddenCards.includes(name),card=document.querySelector('[data-card="'+name+'"]'),toggle=document.querySelector('[data-card-toggle="'+name+'"]');if(card)card.hidden=!visible;if(toggle)toggle.checked=visible})}
|
||||
document.querySelectorAll('[data-card-toggle]').forEach(toggle=>toggle.onchange=()=>{const name=toggle.dataset.cardToggle;hiddenCards=toggle.checked?hiddenCards.filter(v=>v!==name):[...new Set([...hiddenCards,name])];localStorage.setItem('proxmox-hidden-cards',JSON.stringify(hiddenCards));applyCardSettings()});applyCardSettings();
|
||||
function setTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('proxmox-theme',theme);document.querySelectorAll('[data-theme-choice]').forEach(b=>b.classList.toggle('active',b.dataset.themeChoice===theme));drawAllCharts()}document.querySelectorAll('[data-theme-choice]').forEach(b=>b.onclick=()=>setTheme(b.dataset.themeChoice));setTheme(document.documentElement.dataset.theme);el('settingsButton').onclick=()=>el('settingsDialog').showModal();
|
||||
function setTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('proxmox-theme',theme);document.querySelectorAll('[data-theme-choice]').forEach(b=>b.classList.toggle('active',b.dataset.themeChoice===theme));document.querySelectorAll('.chart-section:not([hidden])').forEach(showHistory)}document.querySelectorAll('[data-theme-choice]').forEach(b=>b.onclick=()=>setTheme(b.dataset.themeChoice));setTheme(document.documentElement.dataset.theme);el('settingsButton').onclick=()=>el('settingsDialog').showModal();
|
||||
document.querySelectorAll('[data-settings-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-settings-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-settings-panel]').forEach(p=>p.hidden=p.dataset.settingsPanel!==tab.dataset.settingsTab)});
|
||||
async function loadThresholds(){const response=await fetch('/api/settings/alerts');if(!response.ok)return;alertSettings=await response.json();document.querySelectorAll('[data-threshold]').forEach(input=>input.value=alertSettings[input.dataset.threshold]??'')}
|
||||
el('saveThresholds').onclick=async()=>{const values={};document.querySelectorAll('[data-threshold]').forEach(input=>values[input.dataset.threshold]=Number(input.value));const status=el('thresholdStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/alerts',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(values)});if(response.ok){alertSettings=await response.json();status.textContent='Пороги сохранены и уже применяются.'}else{status.textContent=await response.text()||'Не удалось сохранить настройки.'}};loadThresholds().catch(()=>{});
|
||||
function emailForm(){return {enabled:el('emailEnabled').checked,skipTlsVerify:el('emailSkipTLS').checked,host:el('emailHost').value.trim(),port:Number(el('emailPort').value),username:el('emailUsername').value.trim(),password:el('emailPassword').value,from:el('emailFrom').value.trim(),to:el('emailTo').value.trim(),intervalMinutes:Number(el('emailInterval').value),severities:[...document.querySelectorAll('[data-email-severity]:checked')].map(x=>x.dataset.emailSeverity),sources:[...document.querySelectorAll('[data-email-source]:checked')].map(x=>x.dataset.emailSource)}}
|
||||
async function loadEmail(){const response=await fetch('/api/settings/email');if(!response.ok)return;const s=await response.json();el('emailEnabled').checked=s.enabled;el('emailSkipTLS').checked=!!s.skipTlsVerify;el('emailHost').value=s.host||'';el('emailPort').value=s.port||587;el('emailUsername').value=s.username||'';el('emailPassword').value='';el('emailPassword').placeholder=s.hasPassword?'Сохранён · не изменять':'Пароль';el('emailFrom').value=s.from||'';el('emailTo').value=s.to||'';el('emailInterval').value=s.intervalMinutes||60;document.querySelectorAll('[data-email-severity]').forEach(x=>x.checked=(s.severities||[]).includes(x.dataset.emailSeverity));document.querySelectorAll('[data-email-source]').forEach(x=>x.checked=(s.sources||[]).includes(x.dataset.emailSource))}
|
||||
el('saveEmail').onclick=async()=>{const status=el('emailStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/email',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(emailForm())});status.textContent=response.ok?'Настройки сохранены.':await response.text();if(response.ok)loadEmail()};
|
||||
el('testEmail').onclick=async()=>{const status=el('emailStatus');status.textContent='Отправляем тестовое письмо…';const saved=await fetch('/api/settings/email',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(emailForm())});if(!saved.ok){status.textContent=await saved.text();return}const response=await fetch('/api/settings/email/test',{method:'POST'});status.textContent=response.ok?'Тестовое письмо отправлено.':await response.text()};loadEmail().catch(()=>{});
|
||||
el('installUpdate').onclick=async()=>{const button=el('installUpdate');button.disabled=true;button.textContent='Ставим в очередь…';el('updateMessage').textContent='Создаём запрос на обновление…';try{const response=await fetch('/api/update/request',{method:'POST'});if(!response.ok)throw new Error(await response.text());const status=await response.json();el('updateMessage').textContent=status.message;button.textContent='Обновление выполняется…'}catch(e){button.disabled=false;button.textContent='Попробовать ещё раз';el('updateMessage').textContent='Не удалось запустить обновление.'}};
|
||||
const events=new EventSource('/api/events');events.onmessage=e=>render(JSON.parse(e.data));events.onerror=()=>{el('dot').classList.remove('live');el('status').textContent='Переподключение…'};
|
||||
</script></body></html>`
|
||||
|
||||
Reference in New Issue
Block a user