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) {