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

1# type: ignore

2# src/beautyspot/dashboard.py

3

4 import streamlit as st

5 import streamlit.components.v1 as components

6 import pandas as pd

7 import argparse

8 import html

9 from beautyspot.content_types import ContentType

10 from beautyspot.maintenance import MaintenanceService

11

12

13# CLI引数の解析

14 def get_args():

15 parser = argparse.ArgumentParser()

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

17 args, _ = parser.parse_known_args()

18 return args

19

20

21 try:

22 args = get_args()

23 DB_PATH = args.db

24 except Exception:

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

26 st.stop()

27

28

29# --- Service Initialization ---

30# st.cache_resource でシングルトン管理することで、Streamlit のホットリロード時に

31# MaintenanceService（とその Writer Thread）が重複生成されるのを防ぐ。

32# キャッシュされたリソースはアプリ終了時に Streamlit が自動的に破棄する。

33@st.cache_resource

34 def _init_service(db_path: str) -> MaintenanceService:

35 return MaintenanceService.from_path(db_path)

36

37

38 service = _init_service(DB_PATH)

39

40

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

42 st.title("🌑 beautyspot Dashboard")

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

44

45

46# --- Data Loading ---

47 def load_data():

48 try:

49 return service.get_history(limit=1000)

50 except Exception as e:

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

52 return pd.DataFrame()

53

54

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

56 st.cache_data.clear()

57

58 df = load_data()

59

60 if df.empty:

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

62 st.stop()

63

64# --- Sidebar Filters ---

65 st.sidebar.header("Filter")

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

67

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

69 func_col = "func_identifier"

70 else:

71 func_col = "func_name"

72 funcs = st.sidebar.multiselect(

73 "Function",

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

75)

76 if funcs:

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

78

79 result_types = st.sidebar.multiselect(

80 "Result Type",

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

82)

83 if result_types:

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

85

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

87 if search:

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

89

90

91# --- Main Table ---

92 st.subheader("📋 Tasks")

93 event = st.dataframe(

94 df[

95 [

96 "cache_key",

97 "updated_at",

98 func_col,

99 "input_id",

100 "version",

101 "result_type",

102 "content_type",

103 "result_value",

104 "result_data",

105 ]

106 ],

107 width="stretch",

108 hide_index=True,

109 on_select="rerun",

110 selection_mode="single-row",

111)

112

113# --- Detail & Restore ---

114 st.markdown("---")

115 st.subheader("🔍 Restore Data")

116

117 selected_key = None

118

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

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

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

122

123 if selected_key:

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

125 else:

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

127

128 if selected_key:

129 # サービス経由でデータを取得（デシリアライズ済み）

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

131

132 if row:

133 c_type = row.get("content_type")

134 data = row.get("decoded_data")

135

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

137

138 with col1:

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

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

141 display_row = row.copy()

142 if "result_data" in display_row:

143 del display_row["result_data"]

144 if "decoded_data" in display_row:

145 del display_row["decoded_data"]

146 st.json(display_row)

147

148 with col2:

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

150

151 if data is not None:

152 st.success("Restored successfully!")

153

154 if c_type == ContentType.GRAPHVIZ:

155 try:

156 st.graphviz_chart(data)

157 except Exception:

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

159 st.code(data)

160

161 elif c_type == ContentType.MERMAID:

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

163

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

165 st.image(data)

166

167 elif c_type == ContentType.HTML:

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

169 sandbox_html = (

170 '<iframe sandbox="" srcdoc="'

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

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

173 )

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

175

176 elif c_type == ContentType.JSON:

177 st.json(data)

178

179 elif c_type == ContentType.MARKDOWN:

180 st.markdown(data)

181

182 else:

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

184 st.json(data)

185 else:

186 st.text(str(data))

187 else:

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

189 else:

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

191

192 st.markdown("---")

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

194

195 if selected_key:

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

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

198 st.warning(

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

200 )

201

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

203 try:

204 # サービス経由で削除

205 if service.delete_task(selected_key):

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

207 st.cache_data.clear()

208 st.rerun()

209 else:

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

211

212 except Exception as e:

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

214 else:

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