Coverage for src / lexigram / admin / ui / organisms / task_progress.py: 33%
18 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram.ui import ActionButton, Component, el
8class TaskProgress(Component):
9 """Real-time progress tracking component with SSE updates.
11 Connects to a Server-Sent Events endpoint to display live progress
12 of a background task. Shows progress bar, status, and messages.
14 Args:
15 task_id: Unique identifier for the task
16 title: Progress dialog title
17 auto_close: Whether to auto-close on completion
18 on_complete: URL to redirect to or JS callback on completion
19 stream_url: Custom SSE endpoint URL (defaults to /admin/progress/{task_id}/stream)
20 """
22 def __init__(
23 self,
24 task_id: str,
25 title: str = "Processing...",
26 auto_close: bool = False,
27 on_complete: str | None = None,
28 stream_url: str | None = None,
29 **props,
30 ) -> None:
31 super().__init__(
32 task_id=task_id,
33 title=title,
34 auto_close=auto_close,
35 on_complete=on_complete,
36 stream_url=stream_url,
37 **props,
38 )
39 self.task_id = task_id
40 self.title = title
41 self.auto_close = auto_close
42 self.on_complete = on_complete
43 self.stream_url = stream_url or f"/admin/progress/{task_id}/stream"
45 def render(self) -> Any:
46 # Alpine.js data for managing state
47 alpine_data = {
48 "status": "pending",
49 "progress": 0,
50 "message": "Initializing...",
51 "error": None,
52 "eventSource": None,
53 "init": f"""
54 this.eventSource = new EventSource('{self.stream_url}');
56 this.eventSource.addEventListener('progress', (e) => {{
57 const data = JSON.parse(e.data);
58 this.status = data.status;
59 this.progress = data.progress;
60 this.message = data.message || 'Processing...';
62 if (data.status === 'completed') {{
63 this.eventSource.close();
64 {'window.location.href = "' + self.on_complete + '";' if self.on_complete and self.on_complete.startswith("/") else ""}
65 {self.on_complete + "();" if self.on_complete and not self.on_complete.startswith("/") else ""}
66 }} else if (data.status === 'failed') {{
67 this.error = data.error || 'Task failed';
68 this.eventSource.close();
69 }}
70 }});
72 this.eventSource.addEventListener('error', (e) => {{
73 this.error = 'Connection lost';
74 this.eventSource.close();
75 }});
76 """,
77 "destroy": """
78 if (this.eventSource) {
79 this.eventSource.close();
80 }
81 """,
82 }
84 # Status icon based on current status
85 status_icon = el(
86 "div",
87 el(
88 "svg",
89 el(
90 "path",
91 d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83",
92 stroke_linecap="round",
93 stroke_linejoin="round",
94 stroke_width="2",
95 ),
96 class_="w-12 h-12 text-primary-600 dark:text-primary-400 animate-spin",
97 fill="none",
98 viewBox="0 0 24 24",
99 stroke="currentColor",
100 x_show="status === 'pending' || status === 'running'",
101 ),
102 el(
103 "svg",
104 el(
105 "path",
106 d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
107 stroke_linecap="round",
108 stroke_linejoin="round",
109 stroke_width="2",
110 ),
111 class_="w-12 h-12 text-success",
112 fill="none",
113 viewBox="0 0 24 24",
114 stroke="currentColor",
115 x_show="status === 'completed'",
116 ),
117 el(
118 "svg",
119 el(
120 "path",
121 d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
122 stroke_linecap="round",
123 stroke_linejoin="round",
124 stroke_width="2",
125 ),
126 class_="w-12 h-12 text-destructive",
127 fill="none",
128 viewBox="0 0 24 24",
129 stroke="currentColor",
130 x_show="status === 'failed'",
131 ),
132 class_="mb-4",
133 )
135 # Progress bar
136 progress_bar = el(
137 "div",
138 el(
139 "div",
140 el(
141 "div",
142 class_="h-full bg-primary-600 dark:bg-primary-400 rounded-full transition-all duration-300",
143 x_bind__style="'width: ' + progress + '%'",
144 ),
145 class_="w-full bg-muted rounded-full h-2.5",
146 ),
147 el(
148 "div",
149 el(
150 "span",
151 x_text="progress + '%'",
152 class_="text-sm font-semibold text-foreground",
153 ),
154 class_="mt-2 flex justify-between items-center",
155 ),
156 class_="mb-4",
157 x_show="status !== 'failed'",
158 )
160 # Message display
161 message_display = el(
162 "div",
163 el(
164 "p",
165 x_text="message",
166 class_="text-sm text-muted-foreground text-center",
167 ),
168 class_="mb-4",
169 )
171 # Error display
172 error_display = el(
173 "div",
174 el(
175 "div",
176 el(
177 "p",
178 "An error occurred:",
179 class_="font-semibold text-destructive mb-2",
180 ),
181 el(
182 "p",
183 x_text="error",
184 class_="text-sm text-destructive",
185 ),
186 el(
187 "div",
188 ActionButton(
189 label="Retry",
190 color="danger",
191 size="md",
192 hx_on_click="window.location.reload()",
193 ).render(),
194 class_="mt-4",
195 ),
196 class_="p-4 bg-destructive/10 border border-destructive/30 rounded-lg",
197 ),
198 x_show="error !== null",
199 )
201 return el(
202 "div",
203 el(
204 "div",
205 el(
206 "div",
207 el(
208 "h3",
209 self.title,
210 class_="text-lg font-semibold text-foreground mb-6 text-center",
211 ),
212 status_icon,
213 progress_bar,
214 message_display,
215 error_display,
216 class_="bg-card rounded-xl shadow-lg p-8 max-w-md w-full",
217 ),
218 class_="flex items-center justify-center min-h-screen p-4",
219 ),
220 x_data=f"{alpine_data}",
221 x_init="init()",
222 x_on_before_unload_window="destroy()",
223 class_="fixed inset-0 bg-muted/75 dark:bg-background/75 backdrop-blur-sm z-50",
224 )