labdata 0.1.4__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {labdata-0.1.4 → labdata-0.1.7}/PKG-INFO +3 -1
  2. {labdata-0.1.4 → labdata-0.1.7}/labdata/__init__.py +1 -1
  3. {labdata-0.1.4 → labdata-0.1.7}/labdata/cli.py +4 -0
  4. {labdata-0.1.4 → labdata-0.1.7}/labdata/compute/caiman.py +28 -11
  5. labdata-0.1.7/labdata/dashboard/admin.py +385 -0
  6. labdata-0.1.7/labdata/dashboard/compute.py +302 -0
  7. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/decisiontask.py +38 -22
  8. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/index.py +69 -10
  9. labdata-0.1.7/labdata/dashboard/schema_graph.py +376 -0
  10. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/segmentation.py +22 -0
  11. labdata-0.1.7/labdata/dashboard/sorting.py +1049 -0
  12. {labdata-0.1.4 → labdata-0.1.7}/labdata/export.py +64 -7
  13. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/ephys.py +19 -22
  14. {labdata-0.1.4 → labdata-0.1.7}/labdata.egg-info/PKG-INFO +3 -1
  15. {labdata-0.1.4 → labdata-0.1.7}/labdata.egg-info/SOURCES.txt +2 -0
  16. {labdata-0.1.4 → labdata-0.1.7}/labdata.egg-info/requires.txt +2 -0
  17. {labdata-0.1.4 → labdata-0.1.7}/pyproject.toml +3 -1
  18. labdata-0.1.4/labdata/dashboard/compute.py +0 -112
  19. labdata-0.1.4/labdata/dashboard/sorting.py +0 -207
  20. {labdata-0.1.4 → labdata-0.1.7}/LICENSE +0 -0
  21. {labdata-0.1.4 → labdata-0.1.7}/README.md +0 -0
  22. {labdata-0.1.4 → labdata-0.1.7}/labdata/compute/__init__.py +0 -0
  23. {labdata-0.1.4 → labdata-0.1.7}/labdata/compute/ec2.py +0 -0
  24. {labdata-0.1.4 → labdata-0.1.7}/labdata/compute/ephys.py +0 -0
  25. {labdata-0.1.4 → labdata-0.1.7}/labdata/compute/pose.py +0 -0
  26. {labdata-0.1.4 → labdata-0.1.7}/labdata/compute/schedulers.py +0 -0
  27. {labdata-0.1.4 → labdata-0.1.7}/labdata/compute/singularity.py +0 -0
  28. {labdata-0.1.4 → labdata-0.1.7}/labdata/compute/suite2p.py +0 -0
  29. {labdata-0.1.4 → labdata-0.1.7}/labdata/compute/utils.py +0 -0
  30. {labdata-0.1.4 → labdata-0.1.7}/labdata/copy.py +0 -0
  31. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/__init__.py +0 -0
  32. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/console.py +0 -0
  33. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/explorer/__init__.py +0 -0
  34. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/explorer/cellsegmentation.py +0 -0
  35. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/explorer/spikesorting.py +0 -0
  36. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/notes.py +0 -0
  37. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/procedures.py +0 -0
  38. {labdata-0.1.4 → labdata-0.1.7}/labdata/dashboard/video.py +0 -0
  39. {labdata-0.1.4 → labdata-0.1.7}/labdata/rules/__init__.py +0 -0
  40. {labdata-0.1.4 → labdata-0.1.7}/labdata/rules/ephys.py +0 -0
  41. {labdata-0.1.4 → labdata-0.1.7}/labdata/rules/imaging.py +0 -0
  42. {labdata-0.1.4 → labdata-0.1.7}/labdata/rules/utils.py +0 -0
  43. {labdata-0.1.4 → labdata-0.1.7}/labdata/s3.py +0 -0
  44. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/__init__.py +0 -0
  45. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/general.py +0 -0
  46. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/histology.py +0 -0
  47. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/onephoton.py +0 -0
  48. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/procedures.py +0 -0
  49. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/tasks.py +0 -0
  50. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/twophoton.py +0 -0
  51. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/utils.py +0 -0
  52. {labdata-0.1.4 → labdata-0.1.7}/labdata/schema/video.py +0 -0
  53. {labdata-0.1.4 → labdata-0.1.7}/labdata/stacks.py +0 -0
  54. {labdata-0.1.4 → labdata-0.1.7}/labdata/utils.py +0 -0
  55. {labdata-0.1.4 → labdata-0.1.7}/labdata/widgets.py +0 -0
  56. {labdata-0.1.4 → labdata-0.1.7}/labdata.egg-info/dependency_links.txt +0 -0
  57. {labdata-0.1.4 → labdata-0.1.7}/labdata.egg-info/entry_points.txt +0 -0
  58. {labdata-0.1.4 → labdata-0.1.7}/labdata.egg-info/top_level.txt +0 -0
  59. {labdata-0.1.4 → labdata-0.1.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labdata
3
- Version: 0.1.4
3
+ Version: 0.1.7
4
4
  Summary: Package to manage data in experimental neuroscience labs
5
5
  Author-email: Joao Couto <jpcouto@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/jcouto/labdata
@@ -27,6 +27,8 @@ Requires-Dist: streamlit>1.54; extra == "dashboard"
27
27
  Requires-Dist: streamlit-bokeh; extra == "dashboard"
28
28
  Requires-Dist: scipy; extra == "dashboard"
29
29
  Requires-Dist: scikit-image; extra == "dashboard"
30
+ Requires-Dist: networkx; extra == "dashboard"
31
+ Requires-Dist: streamlit-agraph; extra == "dashboard"
30
32
  Dynamic: license-file
31
33
 
32
34
  ## labdata
@@ -1,4 +1,4 @@
1
- VERSION = "0.1.4"
1
+ VERSION = "0.1.7"
2
2
 
3
3
  from .utils import *
4
4
  from .copy import copy_to_upload_server
@@ -62,6 +62,8 @@ Server commands (don't run on experimental computers):
62
62
  parser.add_argument('--debug' ,action = 'store_true', default = False, help = 'To debug the dash (dashboard) errors.')
63
63
  parser.add_argument('--console', action = 'store_true', default = False,
64
64
  help = 'Include the IPython console tab in the dashboard.')
65
+ parser.add_argument('--admin', action = 'store_true', default = False,
66
+ help = 'Launch dashboard in admin mode (shows admin tab with storage stats and project switcher).')
65
67
  project = _get_project()
66
68
  parser.add_argument('-p','--project',
67
69
  default = project,
@@ -95,6 +97,8 @@ Server commands (don't run on experimental computers):
95
97
  command += f' project={args.project}'
96
98
  if args.console:
97
99
  command += ' console=1'
100
+ if args.admin:
101
+ command += ' admin=1'
98
102
  sub.run(command.split(' '))
99
103
 
100
104
  def subjects(self):
@@ -61,7 +61,7 @@ class CaimanCompute(Suite2pCompute):
61
61
  help = "Expected halfwidth of the neurons in pixels")
62
62
  parser.add_argument('--gsig-filt',
63
63
  action='store', default=None, type = int, nargs = 2,
64
- help = "Filter for 1photon data")
64
+ help = "Filter for 1photon data, will highlight the somas for motion correction (defaults to [6, 6] for 1p data).")
65
65
  parser.add_argument('--nb',
66
66
  action='store', default=0, type = int,
67
67
  help = "number of background components (rank) if positive, set to 0 for CNMFE")
@@ -123,6 +123,7 @@ class CaimanCompute(Suite2pCompute):
123
123
  p = int(args.p),
124
124
  gSig = [int(a) for a in args.gsig],
125
125
  gSiz = [int(a) for a in 2*np.array(args.gsig) + 1],
126
+ gSig_filt = args.gsig_filt,
126
127
  merge_thr = float(args.merge_thr),
127
128
  rf = int(args.rf),
128
129
  stride = int(args.stride),
@@ -154,8 +155,9 @@ class CaimanCompute(Suite2pCompute):
154
155
  dset = (self.schema.Miniscope() & self.dataset_key)
155
156
  cnmfparams = {
156
157
  'motion_correct' : True,
158
+ 'gSig_filt' : [6,6], # 1p needs this for motion correction (spatial filter to register somas)
157
159
  'method_init': 'corr_pnr', # use this for 1 photon
158
- 'K': None, # for 1p
160
+ 'K': None, # for 1p use None
159
161
  'nb': 0, # number of background components (rank) if positive, set to 0 for CNMFE
160
162
  'nb_patch': 0,
161
163
  'low_rank_background': None, # for 1p
@@ -165,7 +167,6 @@ class CaimanCompute(Suite2pCompute):
165
167
  'center_psf': True, # True for 1p
166
168
  'only_init': True, # set it to True to run CNMF-E
167
169
  'method_deconvolution': 'oasis'} # could use 'cvxpy' alternatively
168
-
169
170
  if not len(dset):
170
171
  dset = (self.schema.TwoPhoton() & self.dataset_key)
171
172
  # add the 2p parameters here and do the processing per plane.
@@ -179,25 +180,43 @@ class CaimanCompute(Suite2pCompute):
179
180
  parameters = (self.schema.CellSegmentationParams & f'parameter_set_num = {self.parameter_set_num}').fetch1()
180
181
 
181
182
  params = json.loads(parameters['parameters_dict'])
183
+ excluded = ['detrendWin','denoise_dff','quantileMin']
182
184
  for k in params.keys():
183
- cnmfparams[k] = params[k]
184
-
185
+ if not k in excluded:
186
+ cnmfparams[k] = params[k]
187
+
185
188
  import logging
186
189
  logger = logging.getLogger('caiman')
187
- # Set to logging.INFO if you want much output, potentially much more output
190
+ # Set to logging.INFO if you want more, not very useful output..
188
191
  logger.setLevel(logging.WARNING)
189
192
  handler = logging.StreamHandler()
190
193
  logger.addHandler(handler)
191
194
 
192
195
  import time
193
196
  from caiman.source_extraction.cnmf.params import CNMFParams
194
- parameters = CNMFParams(params_dict = dict({k:cnmfparams[k] for k in ['motion_correct','pw_rigid']},
197
+ kk = ['motion_correct','pw_rigid','max_shifts','gSig_filt']
198
+ if not is_two_photon: # for miniscope make sure the registration has gSig_filt
199
+ if 'gSig_filt' in params.keys():
200
+ if params['gSig_filt'] is None: # that disables it so use the default
201
+ cnmfparams['gSig_filt'] = [6,6]
202
+ print('[caiman] Using default gSig_filt: (6,6) because this is 1photon.')
203
+ else: # add it
204
+ cnmfparams['gSig_filt'] = [6,6]
205
+ print('[caiman] Forcing default gSig_filt: (6,6) because this is 1photon.')
206
+
207
+ parameters = CNMFParams(params_dict = dict({k:cnmfparams[k] for k in kk},
195
208
  fr = frame_rate))
196
209
 
197
210
  os.environ['CAIMAN_DATA'] = f'{temporary_folder}'
198
211
 
199
212
  paths = export_to_tiff(dat,temporary_folder,
200
213
  crop_region = self.parameters['roi'])
214
+ if not self.keep_intermediate:
215
+ if not self.files_existed:
216
+ print('[caiman] Freeing up space by deleting raw data that was downloaded for this compute task.')
217
+ for f in (self.schema.File & dset).get():
218
+ os.unlink(f)
219
+
201
220
  pmotion = parameters.get_group('motion')
202
221
 
203
222
  n_cpus = DEFAULT_N_JOBS
@@ -209,7 +228,7 @@ class CaimanCompute(Suite2pCompute):
209
228
  mot_correct.motion_correct(save_movie=True)
210
229
  print(f'Done with motion correction in {(time.time() - tstart)/60.} min.')
211
230
 
212
- [os.unlink(f) for f in paths]
231
+ [os.unlink(f) for f in paths] # clear raw converted tiff
213
232
  fname_mc = mot_correct.fname_tot_els if cnmfparams['pw_rigid'] else mot_correct.fname_tot_rig
214
233
  if pmotion['pw_rigid']:
215
234
  bord_px = np.ceil(np.maximum(np.max(np.abs(mot_correct.x_shifts_els)),
@@ -222,6 +241,7 @@ class CaimanCompute(Suite2pCompute):
222
241
  fname_new = save_memmap(fname_mc, base_name='memmap_', order='C',
223
242
  border_to_0 = bord_px)
224
243
  [os.unlink(f) for f in fname_mc]
244
+ [os.unlink(f) for f in temporary_folder.rglob("*chunk_*order_F*")]; # clear motion correction temporary
225
245
 
226
246
  from caiman import load_memmap
227
247
  Yr, dims, T = load_memmap(fname_new)
@@ -335,9 +355,6 @@ class CaimanCompute(Suite2pCompute):
335
355
  print(f'[{self.name} job] Removing the temporary folder.')
336
356
  import shutil
337
357
  shutil.rmtree(temporary_folder)
338
- if not self.files_existed:
339
- for f in (self.schema.File & dset).get():
340
- os.unlink(f)
341
358
  else:
342
359
  print(f'[{self.name} job] Kept the temporary folder {temporary_folder}.')
343
360
  self.schema.CellSegmentationMetrics().populate(dkey, display_progress = True)
@@ -0,0 +1,385 @@
1
+ """Admin dashboard tab; file storage statistics per project and users. Adds users to projects"""
2
+ import pandas as pd
3
+ import streamlit as st
4
+ import plotly.express as px
5
+
6
+
7
+ def _query_project(conn, base_db, project_name):
8
+ """
9
+ Use mysql here because we don't want to be loading all projects but need to access Dataset.DataFiles.
10
+
11
+ Run aggregation queries for one project via the database connection.
12
+ Returns (total_bytes, n_files, dtype_rows) where dtype_rows is a list of
13
+ {'project', 'dataset_type', 'total_bytes'} dicts.
14
+ """
15
+ project_db = f"{base_db}_{project_name}"
16
+
17
+ # Total raw data size for the project
18
+ total_bytes, n_files = 0, 0
19
+ try:
20
+ cur = conn.query(f"""
21
+ SELECT COALESCE(SUM(f.file_size), 0), COUNT(*)
22
+ FROM `{base_db}`.`file` f
23
+ INNER JOIN `{project_db}`.`dataset__data_files` df
24
+ ON f.file_path = df.file_path AND f.storage = df.storage
25
+ """)
26
+ row = cur.fetchone()
27
+ if row:
28
+ total_bytes = float(row[0] or 0)
29
+ n_files = int(row[1] or 0)
30
+ except Exception:
31
+ pass
32
+
33
+ # Size per dataset type
34
+ dtype_rows = []
35
+ try:
36
+ cur = conn.query(f"""
37
+ SELECT COALESCE(d.dataset_type, 'unknown'),
38
+ COALESCE(SUM(f.file_size), 0)
39
+ FROM `{base_db}`.`file` f
40
+ INNER JOIN `{project_db}`.`dataset__data_files` df
41
+ ON f.file_path = df.file_path AND f.storage = df.storage
42
+ INNER JOIN `{project_db}`.`dataset` d
43
+ ON df.subject_name = d.subject_name
44
+ AND df.session_name = d.session_name
45
+ AND df.dataset_name = d.dataset_name
46
+ GROUP BY d.dataset_type
47
+ """)
48
+ for dtype, size in cur.fetchall():
49
+ dtype_rows.append({
50
+ 'project': project_name,
51
+ 'dataset_type': dtype,
52
+ 'total_bytes': float(size or 0),
53
+ })
54
+ except Exception:
55
+ pass
56
+
57
+ return total_bytes, n_files, dtype_rows
58
+
59
+
60
+ @st.cache_data(ttl=3600)
61
+ def get_storage_stats(_schema):
62
+ """
63
+ Query file sizes across all projects using the database connection.
64
+
65
+ Returns (summary_df, dtype_df).
66
+ summary_df: project, n_files, total_bytes, size_tb
67
+ dtype_df: project, dataset_type, total_bytes, size_tb
68
+ """
69
+ base_db = _schema.globalschema.database
70
+ conn = _schema.globalschema.connection
71
+ projects = list(_schema.Project.fetch('project_name'))
72
+
73
+ summary_rows, dtype_rows = [], []
74
+ for project_name in projects:
75
+ total_bytes, n_files, dtype = _query_project(conn, base_db, project_name)
76
+ summary_rows.append({
77
+ 'project': project_name,
78
+ 'n_files': n_files,
79
+ 'total_bytes': total_bytes,
80
+ 'size_tb': total_bytes / 1e12,
81
+ })
82
+ dtype_rows.extend(dtype)
83
+
84
+ summary_df = pd.DataFrame(summary_rows).sort_values('total_bytes', ascending=False)
85
+ dtype_df = pd.DataFrame(dtype_rows) if dtype_rows else pd.DataFrame(
86
+ columns=['project', 'dataset_type', 'total_bytes'])
87
+ if not dtype_df.empty:
88
+ dtype_df['size_tb'] = dtype_df['total_bytes'] / 1e12
89
+
90
+ return summary_df, dtype_df
91
+
92
+
93
+ @st.cache_data(ttl=3600)
94
+ def get_schema_sizes(_schema):
95
+ """
96
+ Query information_schema for the on-disk size of every database that
97
+ belongs to this lab installation (prefix = globalschema.database).
98
+ Returns a DataFrame with columns: project, schema_type, size_tb.
99
+ Schema types are derived from the suffix after the project name:
100
+ (none) 'data'
101
+ _computed 'computed'
102
+ anything else → that suffix (e.g. a per-user schema)
103
+ The global schema (no project suffix) is labelled project='(global)', type='global'.
104
+ """
105
+ base_db = _schema.globalschema.database
106
+ conn = _schema.globalschema.connection
107
+ projects = set(_schema.Project.fetch('project_name'))
108
+
109
+ escaped = base_db.replace('_', r'\_')
110
+ try:
111
+ cur = conn.query(
112
+ f"SELECT table_schema,"
113
+ f" COALESCE(SUM(data_length + index_length), 0) AS size_bytes"
114
+ f" FROM information_schema.tables"
115
+ f" WHERE table_schema LIKE '{escaped}%%' ESCAPE '\\\\'"
116
+ f" GROUP BY table_schema"
117
+ )
118
+ rows = cur.fetchall()
119
+ except Exception as e:
120
+ return pd.DataFrame({'_error': [str(e)], 'project': [''], 'schema_type': [''], 'size_tb': [0.0]})
121
+
122
+ records = []
123
+ for schema_name, size_bytes in rows:
124
+ if schema_name == base_db:
125
+ project, schema_type = '(global)', 'global'
126
+ else:
127
+ suffix = schema_name[len(base_db) + 1:] # strip "base_db_"
128
+ # identify which project this belongs to
129
+ matched_project = None
130
+ for p in sorted(projects, key=len, reverse=True):
131
+ if suffix == p or suffix.startswith(p + '_'):
132
+ matched_project = p
133
+ break
134
+ if matched_project is None:
135
+ continue
136
+ remainder = suffix[len(matched_project):] # '' or '_computed' or '_user'
137
+ schema_type = remainder.lstrip('_') or 'data'
138
+ project = matched_project
139
+
140
+ records.append({
141
+ 'project': project,
142
+ 'schema_type': schema_type,
143
+ 'size_tb': float(size_bytes) / 1e12,
144
+ })
145
+
146
+ return pd.DataFrame(records)
147
+
148
+
149
+ @st.cache_data(ttl=300)
150
+ def get_project_users(_schema):
151
+ """
152
+ One row per project with description and comma-separated user list.
153
+ Shows all projects, even those with no assigned users.
154
+ """
155
+ try:
156
+ projects = pd.DataFrame(
157
+ _schema.Project.fetch('project_name', 'project_description', as_dict=True))
158
+ users = pd.DataFrame(
159
+ _schema.Project.User().fetch('project_name', 'user_name', as_dict=True))
160
+ if projects.empty:
161
+ return pd.DataFrame()
162
+ if users.empty:
163
+ projects['Users'] = ''
164
+ else:
165
+ user_summary = (
166
+ users.groupby('project_name')['user_name']
167
+ .apply(lambda u: ', '.join(sorted(u)))
168
+ .reset_index()
169
+ .rename(columns={'user_name': 'Users'})
170
+ )
171
+ projects = projects.merge(user_summary, on='project_name', how='left')
172
+ projects['Users'] = projects['Users'].fillna('')
173
+ return (projects
174
+ .rename(columns={'project_name': 'Project',
175
+ 'project_description': 'Description'})
176
+ [['Project', 'Description', 'Users']])
177
+ except Exception:
178
+ return pd.DataFrame()
179
+
180
+
181
+ @st.dialog("Add user to project")
182
+ def _add_user_dialog(project, user):
183
+ st.write(f"Add **{user}** to project **{project}**?")
184
+ col1, col2 = st.columns(2)
185
+ if col1.button("Confirm", type="primary", key="add_confirm"):
186
+ from labdata.export import add_user_to_project
187
+ try:
188
+ add_user_to_project(project, user)
189
+ get_project_users.clear()
190
+ st.session_state['admin_msg'] = ('success', f'Added {user} to {project}.')
191
+ except Exception as e:
192
+ st.session_state['admin_msg'] = ('error', str(e))
193
+ st.rerun()
194
+ if col2.button("Cancel", key="add_cancel"):
195
+ st.rerun()
196
+
197
+
198
+ @st.dialog("Remove user from project")
199
+ def _remove_user_dialog(project, user):
200
+ st.warning(
201
+ f"Remove **{user}** from project **{project}**? "
202
+ f"This will revoke their database permissions for this project.")
203
+ col1, col2 = st.columns(2)
204
+ if col1.button("Confirm", type="primary", key="remove_confirm"):
205
+ from labdata.export import remove_user_from_project
206
+ try:
207
+ remove_user_from_project(project, user)
208
+ get_project_users.clear()
209
+ st.session_state['admin_msg'] = ('success', f'Removed {user} from {project}.')
210
+ except Exception as e:
211
+ st.session_state['admin_msg'] = ('error', str(e))
212
+ st.rerun()
213
+ if col2.button("Cancel", key="remove_cancel"):
214
+ st.rerun()
215
+
216
+
217
+ def admin_tab(schema, **kwargs):
218
+ st.header("``labdata`` administration and data overview ")
219
+
220
+ with st.spinner("Querying storage stats across all projects..."):
221
+ summary_df, dtype_df = get_storage_stats(schema)
222
+ schema_df = get_schema_sizes(schema)
223
+
224
+ _axis_style = dict(
225
+ showline=True, linecolor='black', linewidth=1.5,
226
+ ticks='outside', tickcolor='black', tickfont=dict(color='black'),
227
+ title_font=dict(color='black'),
228
+ )
229
+ _layout_base = dict(
230
+ paper_bgcolor='white', plot_bgcolor='white',
231
+ font=dict(color='black'),
232
+ hoverlabel=dict(font=dict(color='black')),
233
+ xaxis=_axis_style,
234
+ yaxis=dict(**_axis_style, gridcolor='rgba(0,0,0,0.08)'),
235
+ )
236
+
237
+ if summary_df.empty:
238
+ st.warning("No file data found.")
239
+ else:
240
+ # total size and file count
241
+ col_size, col_files = st.columns(2)
242
+
243
+ with col_size:
244
+ st.subheader("Raw file size per project")
245
+ fig = px.bar(
246
+ summary_df,
247
+ x='project', y='size_tb',
248
+ labels={'project': 'Project', 'size_tb': 'Size (TB)'},
249
+ text=summary_df['size_tb'].apply(lambda v: f'{v:.3f} TB'),
250
+ )
251
+ fig.update_traces(textposition='outside',
252
+ marker_color='rgba(112,112,112,0.7)',
253
+ marker_line_color='black', marker_line_width=1.5)
254
+ fig.update_layout(yaxis_title='Size (TB)', xaxis_title='Project',
255
+ showlegend=False, **_layout_base)
256
+ st.plotly_chart(fig, width='stretch')
257
+
258
+ with col_files:
259
+ st.subheader("Number of raw files per project")
260
+
261
+ fig_n = px.bar(
262
+ summary_df,
263
+ x='project', y='n_files',
264
+ labels={'project': 'Project', 'n_files': 'Number of Files'},
265
+ text='n_files',
266
+ )
267
+ fig_n.update_traces(textposition='outside',
268
+ marker_color='rgba(112,112,112,0.7)',
269
+ marker_line_color='black', marker_line_width=1.5)
270
+ fig_n.update_layout(yaxis_title='Number of Files', xaxis_title='Project',
271
+ showlegend=False, **_layout_base)
272
+ st.plotly_chart(fig_n, width='stretch')
273
+
274
+ # dataset type and schema size
275
+ col_dtype, col_schema = st.columns(2)
276
+
277
+ with col_dtype:
278
+ if not dtype_df.empty:
279
+ st.subheader("Raw file size per dataset type")
280
+ fig2 = px.bar(
281
+ dtype_df,
282
+ x='project', y='size_tb', color='dataset_type',
283
+ barmode='stack',
284
+ labels={'project': 'Project', 'size_tb': 'Size (TB)',
285
+ 'dataset_type': 'Dataset Type'},
286
+ )
287
+ fig2.update_layout(
288
+ yaxis_title='Size (TB)', xaxis_title='Project',
289
+ legend_font=dict(color='black'), legend_title_font=dict(color='black'),
290
+ **_layout_base,
291
+ )
292
+ st.plotly_chart(fig2, width='stretch')
293
+
294
+ with col_schema:
295
+ st.subheader("Database size per project")
296
+ if '_error' in schema_df.columns:
297
+ st.warning(f"Could not query schema sizes: {schema_df['_error'].iloc[0]}")
298
+ elif schema_df.empty:
299
+ st.info("No schema size data found.")
300
+ else:
301
+ fig3 = px.bar(
302
+ schema_df,
303
+ x='project', y='size_tb', color='schema_type',
304
+ barmode='group',
305
+ labels={'project': 'Project', 'size_tb': 'Size (TB)',
306
+ 'schema_type': 'Schema'},
307
+ )
308
+ fig3.update_layout(
309
+ yaxis_title='Size (TB)', xaxis_title='Project',
310
+ legend_font=dict(color='black'), legend_title_font=dict(color='black'),
311
+ **_layout_base,
312
+ )
313
+ st.plotly_chart(fig3, width='stretch')
314
+
315
+ col_users, col_add = st.columns(2)
316
+
317
+ with col_users:
318
+ st.subheader("Users and project assignments")
319
+ users_df = get_project_users(schema)
320
+ if users_df.empty:
321
+ st.info("No user/project associations found.")
322
+ else:
323
+ st.dataframe(
324
+ users_df.style.map(lambda _: 'font-weight: bold', subset=['Project']),
325
+ hide_index=True, width='stretch')
326
+
327
+ with col_add:
328
+ # show feedback message from the last dialog action
329
+ if 'admin_msg' in st.session_state:
330
+ level, text = st.session_state.pop('admin_msg')
331
+ if level == 'success':
332
+ st.success(text)
333
+ else:
334
+ st.error(text)
335
+
336
+ try:
337
+ all_projects = list(schema.Project.fetch('project_name'))
338
+ all_users = list(schema.LabMember.fetch('user_name'))
339
+ except Exception:
340
+ all_projects, all_users = [], []
341
+
342
+ if all_projects and all_users:
343
+ # add user
344
+ st.subheader("Add user to project")
345
+ target_project = st.selectbox('Project', options=all_projects,
346
+ key='add_project_sel')
347
+ target_user = st.selectbox('User', options=all_users,
348
+ key='add_user_sel')
349
+ if st.button('Add User', type='primary', key='add_user_btn'):
350
+ _add_user_dialog(target_project, target_user)
351
+
352
+ # remove user
353
+ st.markdown("""<style>
354
+ [data-testid="stVerticalBlockBorderWrapper"] {
355
+ border: 2px solid red !important;
356
+ border-radius: 6px;
357
+ }
358
+ </style>""", unsafe_allow_html=True)
359
+
360
+ with st.container(border=True):
361
+ st.subheader("Remove user from project")
362
+ remove_project = st.selectbox(
363
+ 'Project', options=all_projects, key='remove_project_sel')
364
+ try:
365
+ project_users = list(
366
+ (schema.Project.User() & f'project_name = "{remove_project}"')
367
+ .fetch('user_name'))
368
+ except Exception:
369
+ project_users = []
370
+
371
+ if project_users:
372
+ remove_user = st.selectbox(
373
+ 'User', options=project_users, key='remove_user_sel')
374
+ if st.button('Remove user', type='primary', key='remove_user_btn'):
375
+ _remove_user_dialog(remove_project, remove_user)
376
+ else:
377
+ st.info("No users assigned to this project.")
378
+ else:
379
+ st.info("No projects or users available.")
380
+
381
+ if st.button("Refresh stats"):
382
+ get_storage_stats.clear()
383
+ get_schema_sizes.clear()
384
+ get_project_users.clear()
385
+ st.rerun()