Add one-click dashboard updates

This commit is contained in:
maxim
2026-07-31 22:32:20 +03:00
parent 2485eae120
commit 58ec47b188
7 changed files with 96 additions and 4 deletions

View File

@@ -47,7 +47,9 @@
Проверка обновлений выполняется отдельным systemd-таймером раз в сутки. Статус
виден во вкладке `Настройки → Обновления` и в центре уведомлений. Доступны два
режима: `notify` только сообщает о новой версии, `auto` скачивает её, сохраняет
предыдущий бинарник как резервный и перезапускает панель.
предыдущий бинарник как резервный и перезапускает панель. В режиме `notify`
обновление можно запустить кнопкой `Проверить и установить обновление` в
настройках. Запрос выполняет отдельная системная служба.
На главном экране находятся компактные карточки CPU, памяти, диска и сети.
Нажатие на карточку открывает подробные показатели.

View 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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
}

View File

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

14
web.go
View File

@@ -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}
</style>
</head>
<body><main>
@@ -344,7 +353,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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><div class="settings-note" id="updateChecked">Проверка запускается автоматически раз в сутки.</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>
@@ -356,7 +365,7 @@ const safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'
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=>'<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;
@@ -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='Переподключение…'};
</script></body></html>`