From 58ec47b18864dd50f452a5e4f6744f229938cb8a Mon Sep 17 00:00:00 2001 From: maxim Date: Fri, 31 Jul 2026 22:32:20 +0300 Subject: [PATCH] Add one-click dashboard updates --- README.md | 4 ++- proxmox-dashboard-update.path | 9 +++++++ scripts/install.sh | 2 ++ scripts/proxmox-dashboard-update | 9 ++++++- update.go | 44 ++++++++++++++++++++++++++++++++ update_test.go | 18 +++++++++++++ web.go | 14 ++++++++-- 7 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 proxmox-dashboard-update.path diff --git a/README.md b/README.md index 8b66f4e..b3b2161 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ Проверка обновлений выполняется отдельным systemd-таймером раз в сутки. Статус виден во вкладке `Настройки → Обновления` и в центре уведомлений. Доступны два режима: `notify` только сообщает о новой версии, `auto` скачивает её, сохраняет -предыдущий бинарник как резервный и перезапускает панель. +предыдущий бинарник как резервный и перезапускает панель. В режиме `notify` +обновление можно запустить кнопкой `Проверить и установить обновление` в +настройках. Запрос выполняет отдельная системная служба. На главном экране находятся компактные карточки CPU, памяти, диска и сети. Нажатие на карточку открывает подробные показатели. diff --git a/proxmox-dashboard-update.path b/proxmox-dashboard-update.path new file mode 100644 index 0000000..98e15d2 --- /dev/null +++ b/proxmox-dashboard-update.path @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index 4a76610..11e2009 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -18,6 +18,7 @@ install -m 0755 "$SOURCE_DIR/scripts/proxmox-dashboard-update" /usr/local/sbin/p 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 { @@ -31,5 +32,6 @@ 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" diff --git a/scripts/proxmox-dashboard-update b/scripts/proxmox-dashboard-update index 98f4515..50982a6 100755 --- a/scripts/proxmox-dashboard-update +++ b/scripts/proxmox-dashboard-update @@ -4,6 +4,7 @@ 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 @@ -15,6 +16,11 @@ 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) @@ -86,11 +92,12 @@ if [ "$current_version" = "$latest_version" ] || [ "$newest" = "$current_version exit 0 fi -if [ "$UPDATE_MODE" != "auto" ]; then +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" diff --git a/update.go b/update.go index b2a2fc9..cb4b33a 100644 --- a/update.go +++ b/update.go @@ -2,11 +2,14 @@ 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"` @@ -31,3 +34,44 @@ func readUpdateStatus() UpdateStatus { 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 +} diff --git a/update_test.go b/update_test.go index 5e456f9..ba18fcf 100644 --- a/update_test.go +++ b/update_test.go @@ -25,3 +25,21 @@ func TestReadUpdateStatusWithoutFile(t *testing.T) { 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) + } +} diff --git a/web.go b/web.go index 75da913..1b4c648 100644 --- a/web.go +++ b/web.go @@ -77,6 +77,14 @@ func routes(collector *cpuCollector, store *Store) http.Handler { }) 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) @@ -178,6 +186,7 @@ dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding: 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)}.chart-head{align-items:flex-start;flex-direction:column}.detail-tabs{overflow-x:auto}} +.save-settings:disabled{cursor:wait;opacity:.55}
@@ -344,7 +353,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Установлена
Доступна
Режим
-
Состояние обновлений
Проверка ещё не выполнялась
Проверка запускается автоматически раз в сутки.
+
Состояние обновлений
Проверка ещё не выполнялась
Проверка запускается автоматически раз в сутки.
@@ -356,7 +365,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||{};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'):'Проверка запускается автоматически раз в сутки.'; + 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=>'
'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+'
'+safe(a.title)+'
'+safe(a.message)+'
').join(''):'
Всё в порядкеАктивных предупреждений сейчас нет.
'; 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; @@ -393,5 +402,6 @@ function setTheme(theme){document.documentElement.dataset.theme=theme;localStora 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(()=>{}); +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='Переподключение…'}; `