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

1# type: ignore

2# src/beautyspot/dashboard.py

3

4 import atexit

5 import streamlit as st

6 import streamlit.components.v1 as components

7 import pandas as pd

8 import argparse

9 import html

10 from beautyspot.content_types import ContentType

11 from beautyspot.maintenance import MaintenanceService

12

13

14# CLI引数の解析

15 def 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

22 try:

23 args = get_args()

24 DB_PATH = args.db

25 except 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クラスを知る必要がない

32 service = MaintenanceService.from_path(DB_PATH)

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

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

35 atexit.register(service.close)

36

37

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

39 st.title("🌑 beautyspot Dashboard")

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

41

42

43# --- Data Loading ---

44 def 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

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

53 st.cache_data.clear()

54

55 df = load_data()

56

57 if df.empty:

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

59 st.stop()

60

61# --- Sidebar Filters ---

62 st.sidebar.header("Filter")

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

64

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

66 func_col = "func_identifier"

67 else:

68 func_col = "func_name"

69 funcs = st.sidebar.multiselect(

70 "Function",

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

72)

73 if funcs:

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

75

76 result_types = st.sidebar.multiselect(

77 "Result Type",

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

79)

80 if result_types:

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

82

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

84 if search:

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

86

87

88# --- Main Table ---

89 st.subheader("📋 Tasks")

90 event = 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 ---

111 st.markdown("---")

112 st.subheader("🔍 Restore Data")

113

114 selected_key = None

115

116 if 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

120 if selected_key:

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

122 else:

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

124

125 if 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

189 st.markdown("---")

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

191

192 if 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}")

211 else:

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