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

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.ui import ActionButton, Component, el 

6 

7 

8class TaskProgress(Component): 

9 """Real-time progress tracking component with SSE updates. 

10 

11 Connects to a Server-Sent Events endpoint to display live progress 

12 of a background task. Shows progress bar, status, and messages. 

13 

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 """ 

21 

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" 

44 

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}'); 

55 

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...'; 

61 

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 }}); 

71 

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 } 

83 

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 ) 

134 

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 ) 

159 

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 ) 

170 

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 ) 

200 

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 )