feat: add advanced update management features

This commit is contained in:
2026-05-03 22:55:51 +02:00
parent a22d2e16ea
commit 137290e3d7
7 changed files with 644 additions and 150 deletions

View File

@@ -16,65 +16,141 @@ const indexHTML = `<!doctype html>
<p id="summary">Pruefe Updates...</p>
</div>
<div class="actions">
<button id="refreshBtn" type="button" title="Updates pruefen"></button>
<button id="refreshBtn" type="button" title="Updates pruefen">Refresh</button>
<button id="installBtn" type="button">Installieren</button>
</div>
</header>
<section class="stats">
<div class="stat">
<span>Aktive Updates</span>
<strong id="activeCount">0</strong>
</div>
<div class="stat">
<span>Ausgeblendet</span>
<strong id="ignoredCount">0</strong>
</div>
<div class="stat">
<span>Freier Speicher</span>
<strong id="diskFree">-</strong>
</div>
<div class="stat">
<span>Kernel</span>
<strong id="kernel">-</strong>
</div>
</section>
<section class="content">
<div class="panel updates-panel">
<div class="panel-head">
<h2>Updates</h2>
<span id="countBadge">0</span>
<div class="main-column">
<div class="panel updates-panel">
<div class="panel-head toolbar">
<div>
<h2>Updates</h2>
<span id="lastCheck">Noch nicht geprueft</span>
</div>
<div class="tools">
<input id="searchInput" type="search" placeholder="Pakete suchen">
<select id="sourceFilter">
<option value="all">Alle Quellen</option>
<option value="pacman">Pacman</option>
<option value="aur">AUR</option>
</select>
</div>
</div>
<div id="warnings" class="warnings" hidden></div>
<div id="emptyState" class="empty">Keine passenden Updates gefunden.</div>
<table id="updatesTable" hidden>
<thead>
<tr>
<th>Quelle</th>
<th>Paket</th>
<th>Aktuell</th>
<th>Verfuegbar</th>
<th></th>
</tr>
</thead>
<tbody id="updatesBody"></tbody>
</table>
</div>
<div id="ignoredPanel" class="panel ignored-panel" hidden>
<div class="panel-head">
<h2>Ausgeblendete Updates</h2>
<span id="ignoredHint"></span>
</div>
<table>
<tbody id="ignoredBody"></tbody>
</table>
</div>
<div id="warnings" class="warnings" hidden></div>
<div id="emptyState" class="empty">Keine Updates gefunden.</div>
<table id="updatesTable" hidden>
<thead>
<tr>
<th>Quelle</th>
<th>Paket</th>
<th>Aktuell</th>
<th>Verfuegbar</th>
</tr>
</thead>
<tbody id="updatesBody"></tbody>
</table>
</div>
<aside class="panel settings-panel">
<div class="panel-head">
<h2>Einstellungen</h2>
<span id="saveState"></span>
</div>
<form id="settingsForm">
<label class="toggle">
<input id="checkAur" type="checkbox">
<span>AUR Updates pruefen</span>
</label>
<aside class="side-column">
<section class="panel system-panel">
<div class="panel-head">
<h2>System</h2>
<span id="lockState"></span>
</div>
<dl class="system-list">
<div>
<dt>AUR Helper</dt>
<dd id="aurHelper">-</dd>
</div>
<div>
<dt>Terminal</dt>
<dd id="terminalStatus">-</dd>
</div>
<div>
<dt>Letzte erfolgreiche Pruefung</dt>
<dd id="lastSuccess">-</dd>
</div>
</dl>
</section>
<label>
<span>Erinnerung alle</span>
<input id="reminderHours" type="number" min="1" step="1">
<small>Stunden</small>
</label>
<section class="panel settings-panel">
<div class="panel-head">
<h2>Einstellungen</h2>
<span id="saveState"></span>
</div>
<form id="settingsForm">
<label class="toggle">
<input id="checkAur" type="checkbox">
<span>AUR Updates pruefen</span>
</label>
<label>
<span>Terminal</span>
<select id="terminal">
<option value="auto">Automatisch</option>
<option value="foot">foot</option>
<option value="kitty">kitty</option>
<option value="alacritty">alacritty</option>
<option value="wezterm">wezterm</option>
<option value="ghostty">ghostty</option>
<option value="konsole">konsole</option>
<option value="xterm">xterm</option>
</select>
</label>
<label class="toggle">
<input id="showIgnored" type="checkbox">
<span>Ausgeblendete anzeigen</span>
</label>
<button type="submit">Speichern</button>
</form>
<label>
<span>Erinnerung alle</span>
<input id="reminderHours" type="number" min="1" step="1">
<small>Stunden</small>
</label>
<label>
<span>Auto-Refresh</span>
<input id="autoRefreshMinutes" type="number" min="0" step="1">
<small>Minuten, 0 deaktiviert</small>
</label>
<label>
<span>Terminal</span>
<select id="terminal">
<option value="auto">Automatisch</option>
<option value="foot">foot</option>
<option value="kitty">kitty</option>
<option value="alacritty">alacritty</option>
<option value="wezterm">wezterm</option>
<option value="ghostty">ghostty</option>
<option value="konsole">konsole</option>
<option value="xterm">xterm</option>
</select>
</label>
<button type="submit">Speichern</button>
</form>
</section>
</aside>
</section>
</main>
@@ -85,16 +161,16 @@ const indexHTML = `<!doctype html>
const appCSS = `
:root {
color-scheme: dark;
--bg: #101114;
--panel: #191b20;
--panel-soft: #20242b;
--text: #f1f5f9;
--muted: #9aa4b2;
--line: #303640;
--accent: #42d392;
--accent-strong: #2ab67c;
--warn: #f5c451;
--danger: #ff6b6b;
--bg: #111317;
--panel: #191d23;
--panel-soft: #222832;
--text: #f4f7fb;
--muted: #9ba7b6;
--line: #303844;
--accent: #44d19d;
--accent-strong: #2eb984;
--warn: #f4c95d;
--danger: #ff6f61;
}
* {
@@ -117,9 +193,9 @@ select {
}
.shell {
width: min(1180px, calc(100vw - 32px));
width: min(1260px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0;
padding: 24px 0;
}
.topbar {
@@ -127,31 +203,40 @@ select {
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
margin-bottom: 16px;
}
h1,
h2,
p {
p,
dl,
dd {
margin: 0;
}
h1 {
font-size: 28px;
font-weight: 720;
font-weight: 740;
}
h2 {
font-size: 16px;
font-weight: 680;
font-size: 15px;
font-weight: 700;
}
#summary,
#lastCheck,
#ignoredHint,
#saveState {
color: var(--muted);
}
#summary {
margin-top: 5px;
color: var(--muted);
}
.actions {
.actions,
.tools {
display: flex;
gap: 10px;
}
@@ -172,15 +257,15 @@ button:hover {
button:disabled {
cursor: progress;
opacity: 0.65;
opacity: 0.62;
}
#installBtn,
form button {
border-color: transparent;
color: #06130e;
background: var(--accent);
color: #07110d;
font-weight: 700;
font-weight: 760;
}
#installBtn:hover,
@@ -188,47 +273,86 @@ form button:hover {
background: var(--accent-strong);
}
.content {
.stats {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 18px;
align-items: start;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.stat,
.panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.stat {
display: grid;
gap: 6px;
min-height: 82px;
padding: 14px;
}
.stat span,
dt,
small {
color: var(--muted);
}
.stat strong {
font-size: 24px;
}
.content {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 16px;
align-items: start;
}
.main-column,
.side-column {
display: grid;
gap: 16px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 54px;
gap: 14px;
min-height: 58px;
padding: 0 16px;
border-bottom: 1px solid var(--line);
}
#countBadge {
display: inline-flex;
.toolbar {
align-items: center;
justify-content: center;
min-width: 32px;
height: 26px;
border-radius: 999px;
color: #07110d;
background: var(--accent);
font-weight: 760;
}
input,
select {
min-height: 40px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 10px;
color: var(--text);
background: var(--panel-soft);
}
#searchInput {
width: min(32vw, 320px);
}
.warnings {
margin: 14px 16px 0;
padding: 12px;
border: 1px solid rgba(245, 196, 81, 0.45);
border: 1px solid rgba(244, 201, 93, 0.45);
border-radius: 8px;
color: var(--warn);
background: rgba(245, 196, 81, 0.08);
background: rgba(244, 201, 93, 0.08);
white-space: pre-wrap;
}
.empty {
@@ -263,13 +387,53 @@ td {
td:first-child {
width: 92px;
color: var(--accent);
font-weight: 700;
font-weight: 720;
}
td:last-child {
width: 132px;
text-align: right;
}
tr:last-child td {
border-bottom: 0;
}
.ghost {
min-height: 34px;
padding: 0 10px;
color: var(--muted);
background: transparent;
}
.danger {
color: var(--danger);
}
.system-list {
display: grid;
gap: 14px;
padding: 16px;
}
.system-list div {
display: flex;
justify-content: space-between;
gap: 16px;
}
.system-list dd {
text-align: right;
}
#lockState {
color: var(--accent);
}
#lockState.locked {
color: var(--danger);
}
form {
display: grid;
gap: 16px;
@@ -288,43 +452,33 @@ label {
color: var(--text);
}
input,
select {
width: 100%;
min-height: 40px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 10px;
color: var(--text);
background: var(--panel-soft);
}
input[type="checkbox"] {
width: 18px;
min-height: 18px;
accent-color: var(--accent);
}
small,
#saveState {
color: var(--muted);
}
@media (max-width: 820px) {
@media (max-width: 920px) {
.shell {
width: min(100vw - 20px, 1180px);
width: min(100vw - 20px, 1260px);
padding: 16px 0;
}
.topbar,
.content {
display: grid;
.content,
.stats {
grid-template-columns: 1fr;
display: grid;
}
.actions {
grid-template-columns: 48px 1fr;
.actions,
.tools {
display: grid;
grid-template-columns: 1fr 1fr;
}
#searchInput {
width: 100%;
}
th:nth-child(3),
@@ -336,19 +490,37 @@ small,
const appJS = `
const summary = document.querySelector("#summary");
const countBadge = document.querySelector("#countBadge");
const activeCount = document.querySelector("#activeCount");
const ignoredCount = document.querySelector("#ignoredCount");
const diskFree = document.querySelector("#diskFree");
const kernel = document.querySelector("#kernel");
const refreshBtn = document.querySelector("#refreshBtn");
const installBtn = document.querySelector("#installBtn");
const updatesTable = document.querySelector("#updatesTable");
const updatesBody = document.querySelector("#updatesBody");
const ignoredPanel = document.querySelector("#ignoredPanel");
const ignoredBody = document.querySelector("#ignoredBody");
const ignoredHint = document.querySelector("#ignoredHint");
const emptyState = document.querySelector("#emptyState");
const warnings = document.querySelector("#warnings");
const searchInput = document.querySelector("#searchInput");
const sourceFilter = document.querySelector("#sourceFilter");
const lastCheck = document.querySelector("#lastCheck");
const lastSuccess = document.querySelector("#lastSuccess");
const aurHelper = document.querySelector("#aurHelper");
const terminalStatus = document.querySelector("#terminalStatus");
const lockState = document.querySelector("#lockState");
const form = document.querySelector("#settingsForm");
const checkAur = document.querySelector("#checkAur");
const showIgnored = document.querySelector("#showIgnored");
const reminderHours = document.querySelector("#reminderHours");
const autoRefreshMinutes = document.querySelector("#autoRefreshMinutes");
const terminal = document.querySelector("#terminal");
const saveState = document.querySelector("#saveState");
let currentData = null;
let refreshTimer = null;
async function request(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
@@ -361,31 +533,116 @@ async function request(path, options = {}) {
return data;
}
function render(data) {
summary.textContent = data.summary;
countBadge.textContent = data.total;
installBtn.disabled = data.total === 0;
function formatDate(value) {
if (!value || value.startsWith("0001-")) {
return "-";
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(value));
}
checkAur.checked = data.settings.check_aur;
reminderHours.value = data.settings.reminder_interval_hours;
terminal.value = data.settings.terminal || "auto";
updatesBody.replaceChildren();
for (const pkg of data.packages) {
const row = document.createElement("tr");
for (const value of [pkg.Source, pkg.Name, pkg.Current || "-", pkg.Available || "-"]) {
const cell = document.createElement("td");
cell.textContent = value;
row.appendChild(cell);
function visiblePackages() {
if (!currentData) {
return [];
}
const query = searchInput.value.trim().toLowerCase();
const source = sourceFilter.value;
return currentData.packages.filter((pkg) => {
if (pkg.Ignored && !currentData.settings.show_ignored) {
return false;
}
updatesBody.appendChild(row);
if (source !== "all" && pkg.Source !== source) {
return false;
}
if (query && !pkg.Name.toLowerCase().includes(query)) {
return false;
}
return !pkg.Ignored;
});
}
function renderTable() {
const packages = visiblePackages();
updatesBody.replaceChildren();
for (const pkg of packages) {
const row = document.createElement("tr");
row.append(cell(pkg.Source));
row.append(cell(pkg.Name));
row.append(cell(pkg.Current || "-"));
row.append(cell(pkg.Available || "-"));
const actionCell = document.createElement("td");
const button = document.createElement("button");
button.className = "ghost";
button.type = "button";
button.textContent = "Ausblenden";
button.addEventListener("click", () => setIgnored(pkg.Name, true));
actionCell.append(button);
row.append(actionCell);
updatesBody.append(row);
}
updatesTable.hidden = data.packages.length === 0;
emptyState.hidden = data.packages.length !== 0;
updatesTable.hidden = packages.length === 0;
emptyState.hidden = packages.length !== 0;
}
function renderIgnored() {
const ignored = currentData.packages.filter((pkg) => pkg.Ignored);
ignoredBody.replaceChildren();
ignoredPanel.hidden = ignored.length === 0 || !currentData.settings.show_ignored;
ignoredHint.textContent = ignored.length === 1 ? "1 Paket" : ignored.length + " Pakete";
for (const pkg of ignored) {
const row = document.createElement("tr");
row.append(cell(pkg.Name));
row.append(cell(pkg.Available || "-"));
const actionCell = document.createElement("td");
const button = document.createElement("button");
button.className = "ghost";
button.type = "button";
button.textContent = "Einblenden";
button.addEventListener("click", () => setIgnored(pkg.Name, false));
actionCell.append(button);
row.append(actionCell);
ignoredBody.append(row);
}
}
function cell(value) {
const element = document.createElement("td");
element.textContent = value;
return element;
}
function render(data) {
currentData = data;
summary.textContent = data.summary;
activeCount.textContent = data.total;
ignoredCount.textContent = data.ignored_total;
installBtn.disabled = data.total === 0 || data.system.pacman_locked;
diskFree.textContent = data.system.disk_free + " frei";
kernel.textContent = data.system.kernel;
aurHelper.textContent = data.system.aur_helper || "nicht gefunden";
terminalStatus.textContent = data.system.terminal || "nicht gefunden";
lastCheck.textContent = "Letzte Pruefung: " + formatDate(data.state.last_check);
lastSuccess.textContent = formatDate(data.state.last_success);
lockState.textContent = data.system.pacman_locked ? "Pacman gesperrt" : "Bereit";
lockState.classList.toggle("locked", data.system.pacman_locked);
checkAur.checked = data.settings.check_aur;
showIgnored.checked = data.settings.show_ignored;
reminderHours.value = data.settings.reminder_interval_hours;
autoRefreshMinutes.value = data.settings.auto_refresh_minutes;
terminal.value = data.settings.terminal || "auto";
warnings.hidden = data.warnings.length === 0;
warnings.textContent = data.warnings.join("\\n");
renderTable();
renderIgnored();
scheduleAutoRefresh();
}
async function loadStatus() {
@@ -400,7 +657,26 @@ async function loadStatus() {
}
}
function scheduleAutoRefresh() {
clearInterval(refreshTimer);
const minutes = Number(autoRefreshMinutes.value);
if (!minutes || minutes < 1) {
return;
}
refreshTimer = setInterval(loadStatus, minutes * 60 * 1000);
}
async function setIgnored(name, ignored) {
await request("/api/ignore", {
method: "POST",
body: JSON.stringify({ name, ignored }),
});
await loadStatus();
}
refreshBtn.addEventListener("click", loadStatus);
searchInput.addEventListener("input", renderTable);
sourceFilter.addEventListener("change", renderTable);
installBtn.addEventListener("click", async () => {
installBtn.disabled = true;
@@ -422,8 +698,11 @@ form.addEventListener("submit", async (event) => {
method: "PUT",
body: JSON.stringify({
check_aur: checkAur.checked,
show_ignored: showIgnored.checked,
reminder_interval_hours: Number(reminderHours.value),
auto_refresh_minutes: Number(autoRefreshMinutes.value),
terminal: terminal.value,
ignored_packages: currentData?.settings.ignored_packages || [],
}),
});
saveState.textContent = "Gespeichert";

View File

@@ -8,40 +8,62 @@ import (
"log"
"net"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
"time"
"lazy-update-manager/internal/config"
"lazy-update-manager/internal/state"
"lazy-update-manager/internal/updater"
)
type Server struct {
configPath string
statePath string
}
type StatusResponse struct {
Summary string `json:"summary"`
Total int `json:"total"`
Packages []updater.Package `json:"packages"`
Warnings []string `json:"warnings"`
Settings config.Config `json:"settings"`
Summary string `json:"summary"`
Total int `json:"total"`
IgnoredTotal int `json:"ignored_total"`
Packages []updater.Package `json:"packages"`
Warnings []string `json:"warnings"`
Settings config.Config `json:"settings"`
State state.Store `json:"state"`
System SystemStatus `json:"system"`
}
func Run(configPath string, openBrowser bool) error {
type SystemStatus struct {
AURHelper string `json:"aur_helper"`
Terminal string `json:"terminal"`
PacmanLocked bool `json:"pacman_locked"`
DiskFree string `json:"disk_free"`
DiskFreePercent int `json:"disk_free_percent"`
Kernel string `json:"kernel"`
}
type ignoreRequest struct {
Name string `json:"name"`
Ignored bool `json:"ignored"`
}
func Run(configPath, statePath string, openBrowser bool) error {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
server := &Server{configPath: configPath}
server := &Server{configPath: configPath, statePath: statePath}
mux := http.NewServeMux()
mux.HandleFunc("/", server.index)
mux.HandleFunc("/app.css", server.css)
mux.HandleFunc("/app.js", server.js)
mux.HandleFunc("/api/status", server.status)
mux.HandleFunc("/api/settings", server.settings)
mux.HandleFunc("/api/ignore", server.ignore)
mux.HandleFunc("/api/install", server.install)
url := "http://" + listener.Addr().String()
@@ -74,11 +96,28 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
result, err := updater.CheckWithOptions(ctx, updater.Options{CheckAUR: cfg.CheckAUR})
result, err := updater.CheckWithOptions(ctx, updater.Options{
CheckAUR: cfg.CheckAUR,
IgnoredPackages: cfg.IgnoredPackages,
})
if err != nil {
writeError(w, err, http.StatusInternalServerError)
return
}
now := time.Now()
store, err := state.Load(s.statePath)
if err != nil {
writeError(w, err, http.StatusInternalServerError)
return
}
store.LastCheck = now
store.LastSuccess = now
store.LastUpdateCount = result.Total()
store.LastSummary = result.Summary()
if err := state.Save(s.statePath, store); err != nil {
writeError(w, err, http.StatusInternalServerError)
return
}
packages := result.Packages
if packages == nil {
@@ -90,11 +129,14 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, StatusResponse{
Summary: result.Summary(),
Total: result.Total(),
Packages: packages,
Warnings: warnings,
Settings: cfg,
Summary: result.Summary(),
Total: result.Total(),
IgnoredTotal: result.IgnoredTotal(),
Packages: packages,
Warnings: warnings,
Settings: cfg,
State: store,
System: systemStatus(cfg),
})
}
@@ -123,6 +165,38 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Server) ignore(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req ignoreRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, err, http.StatusBadRequest)
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeError(w, errors.New("package name is required"), http.StatusBadRequest)
return
}
cfg, err := config.Load(s.configPath)
if err != nil {
writeError(w, err, http.StatusInternalServerError)
return
}
cfg.IgnoredPackages = setIgnored(cfg.IgnoredPackages, req.Name, req.Ignored)
if err := config.Save(s.configPath, cfg); err != nil {
writeError(w, err, http.StatusInternalServerError)
return
}
writeJSON(w, cfg)
}
func (s *Server) install(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -150,6 +224,80 @@ func (s *Server) install(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "started"})
}
func setIgnored(names []string, name string, ignored bool) []string {
seen := map[string]bool{}
next := []string{}
for _, item := range names {
item = strings.TrimSpace(item)
if item == "" || item == name || seen[item] {
continue
}
seen[item] = true
next = append(next, item)
}
if ignored {
next = append(next, name)
}
return next
}
func systemStatus(cfg config.Config) SystemStatus {
terminal, _, _ := terminalCommand(cfg)
return SystemStatus{
AURHelper: updater.AURHelper(),
Terminal: terminal,
PacmanLocked: fileExists("/var/lib/pacman/db.lck"),
DiskFree: diskFree("/"),
DiskFreePercent: diskFreePercent("/"),
Kernel: kernelVersion(),
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func diskFree(path string) string {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return "unknown"
}
bytes := stat.Bavail * uint64(stat.Bsize)
return humanBytes(bytes)
}
func diskFreePercent(path string) int {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil || stat.Blocks == 0 {
return 0
}
return int((stat.Bavail * 100) / stat.Blocks)
}
func humanBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
value := float64(bytes)
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
value = value / unit
if value < unit {
return fmt.Sprintf("%.1f %s", value, suffix)
}
}
return fmt.Sprintf("%.1f PiB", value/unit)
}
func kernelVersion() string {
out, err := exec.Command("uname", "-r").Output()
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(out))
}
func terminalCommand(cfg config.Config) (string, []string, error) {
updateCommand := "lazy-update-manager update; printf '\\nDone. Press enter to close... '; read _"
if helper := updater.AURHelper(); helper != "" && cfg.CheckAUR {