Coverage for src / beautyspot / dashboard.py: 0%

117 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-03-09 14:39 +0900

1# type: ignore 

2# src/beautyspot/dashboard.py 

3 

4import atexit 

5import streamlit as st 

6import streamlit.components.v1 as components 

7import pandas as pd 

8import argparse 

9import html 

10from beautyspot.content_types import ContentType 

11from beautyspot.maintenance import MaintenanceService 

12 

13 

14# CLI引数の解析 

15def get_args(): 

16 parser = argparse.ArgumentParser() 

17 parser.add_argument("--db", type=str, required=True) 

18 args, _ = parser.parse_known_args() 

19 return args 

20 

21 

22try: 

23 args = get_args() 

24 DB_PATH = args.db 

25except Exception: 

26 st.error("Database path not provided. Run via `beautyspot ui <db>`") 

27 st.stop() 

28 

29 

30# --- Service Initialization --- 

31# UIレイヤーは具体的なDBクラスやStorageクラスを知る必要がない 

32service = MaintenanceService.from_path(DB_PATH) 

33# from_path で生成した service は Writer Thread を所有するため、 

34# プロセス終了時に確実に close() して Thread リークを防ぐ。 

35atexit.register(service.close) 

36 

37 

38st.set_page_config(page_title="beautyspot Dashboard", layout="wide", page_icon="🌑") 

39st.title("🌑 beautyspot Dashboard") 

40st.caption(f"Database: `{DB_PATH}`") 

41 

42 

43# --- Data Loading --- 

44def load_data(): 

45 try: 

46 return service.get_history(limit=1000) 

47 except Exception as e: 

48 st.error(f"Error reading DB: {e}") 

49 return pd.DataFrame() 

50 

51 

52if st.button("🔄 Refresh"): 

53 st.cache_data.clear() 

54 

55df = load_data() 

56 

57if df.empty: 

58 st.info("No tasks recorded yet.") 

59 st.stop() 

60 

61# --- Sidebar Filters --- 

62st.sidebar.header("Filter") 

63st.sidebar.metric("Total Records", len(df)) 

64 

65if "func_identifier" in df.columns and df["func_identifier"].notna().any(): # type: ignore[union-attr] 

66 func_col = "func_identifier" 

67else: 

68 func_col = "func_name" 

69funcs = st.sidebar.multiselect( 

70 "Function", 

71 df[func_col].dropna().unique().tolist(), # type: ignore[union-attr] 

72) 

73if funcs: 

74 df = df[df[func_col].isin(funcs)] # type: ignore[union-attr] 

75 

76result_types = st.sidebar.multiselect( 

77 "Result Type", 

78 df["result_type"].unique().tolist(), # type: ignore[union-attr] 

79) 

80if result_types: 

81 df = df[df["result_type"].isin(result_types)] # type: ignore[union-attr] 

82 

83search = st.sidebar.text_input("Search Input ID") 

84if search: 

85 df = df[df["input_id"].str.contains(search, na=False)] # type: ignore[union-attr] 

86 

87 

88# --- Main Table --- 

89st.subheader("📋 Tasks") 

90event = st.dataframe( 

91 df[ 

92 [ 

93 "cache_key", 

94 "updated_at", 

95 func_col, 

96 "input_id", 

97 "version", 

98 "result_type", 

99 "content_type", 

100 "result_value", 

101 "result_data", 

102 ] 

103 ], 

104 width="stretch", 

105 hide_index=True, 

106 on_select="rerun", 

107 selection_mode="single-row", 

108) 

109 

110# --- Detail & Restore --- 

111st.markdown("---") 

112st.subheader("🔍 Restore Data") 

113 

114selected_key = None 

115 

116if len(event.selection.rows) > 0: # type: ignore[union-attr] 

117 row_idx = event.selection.rows[0] # type: ignore[union-attr] 

118 selected_key = df.iloc[row_idx]["cache_key"] # type: ignore[union-attr] 

119 

120if selected_key: 

121 st.info(f"Selected from table: `{selected_key}`") 

122else: 

123 st.info("Select Record from Table") 

124 

125if selected_key: 

126 # サービス経由でデータを取得(デシリアライズ済み) 

127 row = service.get_task_detail(selected_key, include_expired=True) 

128 

129 if row: 

130 c_type = row.get("content_type") 

131 data = row.get("decoded_data") 

132 

133 col1, col2 = st.columns([1, 2]) 

134 

135 with col1: 

136 st.write("**Metadata**") 

137 # メタデータ表示用にblobデータを隠す 

138 display_row = row.copy() 

139 if "result_data" in display_row: 

140 del display_row["result_data"] 

141 if "decoded_data" in display_row: 

142 del display_row["decoded_data"] 

143 st.json(display_row) 

144 

145 with col2: 

146 st.write(f"**Content**: {c_type or 'Unknown Type'}") 

147 

148 if data is not None: 

149 st.success("Restored successfully!") 

150 

151 if c_type == ContentType.GRAPHVIZ: 

152 try: 

153 st.graphviz_chart(data) 

154 except Exception: 

155 st.error("Graphviz rendering failed.") 

156 st.code(data) 

157 

158 elif c_type == ContentType.MERMAID: 

159 st.code(data, language="mermaid") 

160 

161 elif c_type == ContentType.PNG or c_type == ContentType.JPEG: 

162 st.image(data) 

163 

164 elif c_type == ContentType.HTML: 

165 # sandboxed iframe: 全制限で安全に HTML を表示 

166 sandbox_html = ( 

167 '<iframe sandbox="" srcdoc="' 

168 + html.escape(data, quote=True) 

169 + '" style="width:100%;height:600px;border:none;"></iframe>' 

170 ) 

171 components.html(sandbox_html, height=620, scrolling=True) 

172 

173 elif c_type == ContentType.JSON: 

174 st.json(data) 

175 

176 elif c_type == ContentType.MARKDOWN: 

177 st.markdown(data) 

178 

179 else: 

180 if isinstance(data, (dict, list)): 

181 st.json(data) 

182 else: 

183 st.text(str(data)) 

184 else: 

185 st.warning("Data could not be restored (decoding failed or empty).") 

186 else: 

187 st.error("Record not found in DB.") 

188 

189st.markdown("---") 

190st.subheader("🗑️ Danger Zone") 

191 

192if selected_key: 

193 with st.popover("Delete Record", use_container_width=True): 

194 st.markdown(f"Are you sure you want to delete **`{selected_key}`**?") 

195 st.warning( 

196 "This action cannot be undone. The database record and associated blob file will be removed." 

197 ) 

198 

199 if st.button("Confirm Delete", type="primary"): 

200 try: 

201 # サービス経由で削除 

202 if service.delete_task(selected_key): 

203 st.success(f"Deleted `{selected_key}`") 

204 st.cache_data.clear() 

205 st.rerun() 

206 else: 

207 st.error("Record not found or failed to delete.") 

208 

209 except Exception as e: 

210 st.error(f"Failed to delete: {e}") 

211else: 

212 st.info("Select a record to enable deletion.")