Add one-click dashboard updates
This commit is contained in:
@@ -47,7 +47,9 @@
|
||||
Проверка обновлений выполняется отдельным systemd-таймером раз в сутки. Статус
|
||||
виден во вкладке `Настройки → Обновления` и в центре уведомлений. Доступны два
|
||||
режима: `notify` только сообщает о новой версии, `auto` скачивает её, сохраняет
|
||||
предыдущий бинарник как резервный и перезапускает панель.
|
||||
предыдущий бинарник как резервный и перезапускает панель. В режиме `notify`
|
||||
обновление можно запустить кнопкой `Проверить и установить обновление` в
|
||||
настройках. Запрос выполняет отдельная системная служба.
|
||||
|
||||
На главном экране находятся компактные карточки CPU, памяти, диска и сети.
|
||||
Нажатие на карточку открывает подробные показатели.
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
44
update.go
44
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
|
||||
}
|
||||
|
||||
@@ -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
14
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}
|
||||
</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=>({'&':'&','<':'<','>':'
|
||||
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>`
|
||||
|
||||
Reference in New Issue
Block a user