banners refresh and undismiss
frdel committed
Feb 4, 2026 at 10:28 UTC
19da93f5355ebff42ba655ecd5bac8d22cba77c1
3 files changed
+143
-24
python/extensions/banners/_30_system_resources.py
+55
-22
@@ -10,15 +10,25 @@ class SystemResourcesCheck(Extension):
10
except Exception:
11
cpu_percent = None
12
13
+ try:
14
+ cpu_cores = psutil.cpu_count(logical=True)
15
+ except Exception:
16
+ cpu_cores = None
17
+
18
load_avg = self._get_load_average()
19
20
try:
21
vm = psutil.virtual_memory()
22
ram_percent = vm.percent
23
+ ram_used_gb = vm.used / (1024 ** 3)
24
+ ram_total_gb = vm.total / (1024 ** 3)
25
except Exception:
26
ram_percent = None
27
21
- disk_percent, disk_path = self._get_disk_usage_percent()
28
+ ram_used_gb = None
29
+ ram_total_gb = None
30
+
31
+ disk_percent, disk_used_gb, disk_total_gb, disk_path = self._get_disk_usage()
32
33
try:
34
net = psutil.net_io_counters()
@@ -33,12 +43,21 @@ class SystemResourcesCheck(Extension):
43
la1, la5, la15 = load_avg
44
load_value = f"{la1:.2f} / {la5:.2f} / {la15:.2f}"
45
36
- disk_value = "N/A"
37
- if disk_percent is not None:
38
- disk_value = f"{disk_percent:.0f}% ({disk_path})"
46
+ if disk_percent is None or disk_used_gb is None or disk_total_gb is None:
47
+ disk_value = "N/A"
48
+ else:
49
+ disk_value = f"{disk_used_gb:.2f}/{disk_total_gb:.2f} GB"
50
+
51
+ if cpu_percent is None:
52
+ cpu_value = "N/A"
53
+ else:
54
+ cores_value = "" if cpu_cores is None else f" ({cpu_cores} cores)"
55
+ cpu_value = f"{cpu_percent:.0f}%{cores_value}"
56
40
- cpu_value = "N/A" if cpu_percent is None else f"{cpu_percent:.0f}%"
41
- ram_value = "N/A" if ram_percent is None else f"{ram_percent:.0f}%"
57
+ if ram_percent is None or ram_used_gb is None or ram_total_gb is None:
58
+ ram_value = "N/A"
59
+ else:
60
+ ram_value = f"{ram_used_gb:.2f}/{ram_total_gb:.2f} GB"
61
62
cpu_bar = self._bar_html(cpu_percent)
63
ram_bar = self._bar_html(ram_percent)
@@ -50,20 +69,32 @@ class SystemResourcesCheck(Extension):
69
"priority": 10,
70
"title": "System Resources",
71
"html": (
53
- "<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:12px 14px;\">"
54
- "<div style=\"grid-column:1 / -1;display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;align-items:center;\">"
55
- f"<div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7\">CPU</div>"
56
- f"<div style=\"display:flex;gap:10px;align-items:center;\"><div style=\"font-weight:700\">{cpu_value}</div>{cpu_bar}</div>"
57
- f"<div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7\">RAM</div>"
58
- f"<div style=\"display:flex;gap:10px;align-items:center;\"><div style=\"font-weight:700\">{ram_value}</div>{ram_bar}</div>"
59
- f"<div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7\">Disk</div>"
60
- f"<div style=\"display:flex;gap:10px;align-items:center;\"><div style=\"font-weight:700\">{disk_value}</div>{disk_bar}</div>"
72
+ "<div style=\"display:flex;flex-direction:column;gap:14px;padding-top:6px;\">"
73
+ "<div style=\"display:grid;grid-template-columns:140px 1fr;row-gap:12px;column-gap:12px;align-items:center;\">"
74
+ "<div style=\"display:flex;flex-direction:column;gap:6px;\">"
75
+ "<div style=\"font-size:12px;letter-spacing:.10em;text-transform:uppercase;opacity:.65;line-height:1.1\">CPU</div>"
76
+ f"<div style=\"font-weight:750;font-variant-numeric:tabular-nums;letter-spacing:.02em;line-height:1.1\">{cpu_value}</div>"
77
+ "</div>"
78
+ f"{cpu_bar}"
79
+ "<div style=\"display:flex;flex-direction:column;gap:6px;\">"
80
+ "<div style=\"font-size:12px;letter-spacing:.10em;text-transform:uppercase;opacity:.65;line-height:1.1\">RAM</div>"
81
+ f"<div style=\"font-weight:750;font-variant-numeric:tabular-nums;letter-spacing:.02em;line-height:1.1\">{ram_value}</div>"
82
+ "</div>"
83
+ f"{ram_bar}"
84
+ "<div style=\"display:flex;flex-direction:column;gap:6px;\">"
85
+ "<div style=\"font-size:12px;letter-spacing:.10em;text-transform:uppercase;opacity:.65;line-height:1.1\">Disk</div>"
86
+ f"<div style=\"font-weight:750;font-variant-numeric:tabular-nums;letter-spacing:.02em;line-height:1.1\">{disk_value}</div>"
87
+ "</div>"
88
+ f"{disk_bar}"
89
+ "</div>"
90
+ "<div style=\"height:1px;background:rgba(255,255,255,.08);\"></div>"
91
+ "<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:10px 14px;\">"
92
+ f"<div><div style=\"font-size:12px;letter-spacing:.10em;text-transform:uppercase;opacity:.65;margin-bottom:6px;\">Load (1/5/15)</div><div style=\"font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;opacity:.85;font-variant-numeric:tabular-nums\">{load_value}</div></div>"
93
+ f"<div><div style=\"font-size:12px;letter-spacing:.10em;text-transform:uppercase;opacity:.65;margin-bottom:6px;\">Net (since boot)</div><div style=\"font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;opacity:.85;font-variant-numeric:tabular-nums\">{net_sent} sent / {net_recv} recv</div></div>"
94
"</div>"
62
- f"<div style=\"padding-top:2px;\"><div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7;margin-bottom:4px;\">Load (1/5/15)</div><div style=\"font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;opacity:.85\">{load_value}</div></div>"
63
- f"<div style=\"padding-top:2px;\"><div style=\"font-size:12px;letter-spacing:.08em;text-transform:uppercase;opacity:.7;margin-bottom:4px;\">Net (since boot)</div><div style=\"font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;opacity:.85\">{net_sent} sent / {net_recv} recv</div></div>"
95
"</div>"
96
),
66
- "dismissible": False,
97
+ "dismissible": True,
98
"source": "backend",
99
})
100
@@ -80,8 +111,8 @@ class SystemResourcesCheck(Extension):
111
color = "#22c55e"
112
113
return (
83
- "<div style=\"flex:1;min-width:120px;max-width:220px;height:8px;border-radius:999px;"
84
- "background:rgba(255,255,255,.10);overflow:hidden;\">"
114
+ "<div style=\"flex:1;min-width:140px;max-width:260px;height:10px;border-radius:999px;"
115
+ "background:rgba(255,255,255,.10);overflow:hidden;box-shadow:inset 0 0 0 1px rgba(255,255,255,.08);\">"
116
f"<div style=\"height:100%;width:{p:.0f}%;background:{color};border-radius:999px;\"></div>"
117
"</div>"
118
)
@@ -92,14 +123,16 @@ class SystemResourcesCheck(Extension):
123
except Exception:
124
return None
125
95
- def _get_disk_usage_percent(self) -> tuple[float | None, str]:
126
+ def _get_disk_usage(self) -> tuple[float | None, float | None, float | None, str]:
127
for path in ["/", os.path.expanduser("~")]:
128
try:
129
usage = psutil.disk_usage(path)
99
- return usage.percent, path
130
+ used_gb = usage.used / (1024 ** 3)
131
+ total_gb = usage.total / (1024 ** 3)
132
+ return usage.percent, used_gb, total_gb, path
133
except Exception:
134
continue
102
- return None, "/"
135
+ return None, None, None, "/"
136
137
def _format_bytes(self, value: int) -> str:
138
size = float(value)
webui/components/welcome/welcome-screen.html
+66
@@ -50,6 +50,25 @@
50
</div>
51
</div>
52
53
+ <div class="welcome-actions-footer">
54
+ <button type="button"
55
+ class="welcome-refresh-button"
56
+ @click="refreshBanners(true)"
57
+ :disabled="bannersLoading"
58
+ title="Refresh banners">
59
+ <span class="material-symbols-outlined" :class="{ 'welcome-spin': bannersLoading }">refresh</span>
60
+ </button>
61
+
62
+ <button type="button"
63
+ class="welcome-refresh-button"
64
+ @click="undismissBanners()"
65
+ x-show="hasDismissedBanners"
66
+ :disabled="bannersLoading"
67
+ title="Undismiss all banners">
68
+ <span class="material-symbols-outlined">undo</span>
69
+ </button>
70
+ </div>
71
+
72
<!-- Banner Section -->
73
<div class="welcome-banners" x-show="banners && banners.length > 0">
74
<template x-for="banner in sortedBanners" :key="banner.id">
@@ -176,6 +195,53 @@
195
margin: 1.5rem 0;
196
}
197
198
+ .welcome-actions-footer {
199
+ max-width: 640px;
200
+ width: 100%;
201
+ display: flex;
202
+ justify-content: flex-start;
203
+ gap: 0.5rem;
204
+ margin-top: -0.75rem;
205
+ }
206
+
207
+ .welcome-refresh-button {
208
+ width: 38px;
209
+ height: 38px;
210
+ display: flex;
211
+ align-items: center;
212
+ justify-content: center;
213
+ background: transparent;
214
+ border: 1px solid var(--color-border);
215
+ border-radius: 10px;
216
+ color: var(--color-secondary);
217
+ cursor: pointer;
218
+ transition: all 0.2s ease;
219
+ }
220
+
221
+ .welcome-refresh-button:hover {
222
+ border-color: var(--color-primary);
223
+ background: var(--color-message-bg);
224
+ color: var(--color-text);
225
+ }
226
+
227
+ .welcome-refresh-button:disabled {
228
+ opacity: 0.6;
229
+ cursor: default;
230
+ }
231
+
232
+ .welcome-refresh-button .material-symbols-outlined {
233
+ font-size: 1.25rem;
234
+ }
235
+
236
+ .welcome-spin {
237
+ animation: welcome-spin 0.9s linear infinite;
238
+ }
239
+
240
+ @keyframes welcome-spin {
241
+ from { transform: rotate(0deg); }
242
+ to { transform: rotate(360deg); }
243
+ }
244
+
245
.welcome-action-card {
246
background: var(--color-panel);
247
border: 1px solid var(--color-border);
webui/components/welcome/welcome-store.js
+22
-2
@@ -12,6 +12,7 @@ const model = {
12
banners: [],
13
bannersLoading: false,
14
lastBannerRefresh: 0,
15
+ hasDismissedBanners: false,
16
17
init() {
18
// Initialize visibility based on current context
@@ -107,9 +108,9 @@ const model = {
108
},
109
110
// Refresh banners: frontend checks → backend checks → merge
110
- async refreshBanners() {
111
+ async refreshBanners(force = false) {
112
const now = Date.now();
112
- if (now - this.lastBannerRefresh < 1000) return;
113
+ if (!force && now - this.lastBannerRefresh < 1000) return;
114
this.lastBannerRefresh = now;
115
this.bannersLoading = true;
116
@@ -117,10 +118,20 @@ const model = {
118
const frontendContext = this.buildFrontendContext();
119
const frontendBanners = this.runFrontendBannerChecks();
120
const backendBanners = await this.runBackendBannerChecks(frontendBanners, frontendContext);
121
+
122
+ const dismissed = this.getDismissedBannerIds();
123
+ const loadIds = new Set(
124
+ [...frontendBanners, ...backendBanners]
125
+ .filter(b => b?.id && b.dismissible !== false)
126
+ .map(b => b.id)
127
+ );
128
+ this.hasDismissedBanners = Array.from(loadIds).some(id => dismissed.has(id));
129
+
130
this.banners = this.mergeBanners(frontendBanners, backendBanners);
131
} catch (error) {
132
console.error("Failed to refresh banners:", error);
133
this.banners = this.runFrontendBannerChecks();
134
+ this.hasDismissedBanners = false;
135
} finally {
136
this.bannersLoading = false;
137
}
@@ -151,6 +162,15 @@ const model = {
162
dismissed.push(bannerId);
163
storage.setItem('dismissed_banners', JSON.stringify(dismissed));
164
}
165
+
166
+ this.hasDismissedBanners = this.getDismissedBannerIds().size > 0;
167
+ },
168
+
169
+ undismissBanners() {
170
+ localStorage.removeItem("dismissed_banners");
171
+ sessionStorage.removeItem("dismissed_banners");
172
+ this.hasDismissedBanners = false;
173
+ this.refreshBanners(true);
174
},
175
176
getBannerClass(type) {