meerschaum 2.1.7__py3-none-any.whl → 2.2.0.dev2__py3-none-any.whl

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 (28) hide show
  1. meerschaum/_internal/arguments/_parser.py +3 -0
  2. meerschaum/_internal/entry.py +2 -1
  3. meerschaum/actions/install.py +7 -3
  4. meerschaum/actions/sync.py +7 -3
  5. meerschaum/api/dash/callbacks/dashboard.py +88 -13
  6. meerschaum/api/dash/callbacks/jobs.py +55 -3
  7. meerschaum/api/dash/jobs.py +34 -8
  8. meerschaum/api/dash/pipes.py +105 -18
  9. meerschaum/api/resources/static/js/xterm.js +1 -1
  10. meerschaum/config/_version.py +1 -1
  11. meerschaum/config/stack/__init__.py +0 -1
  12. meerschaum/connectors/api/_plugins.py +2 -1
  13. meerschaum/connectors/sql/_create_engine.py +5 -5
  14. meerschaum/plugins/_Plugin.py +11 -2
  15. meerschaum/utils/daemon/Daemon.py +11 -3
  16. meerschaum/utils/dtypes/__init__.py +9 -5
  17. meerschaum/utils/packages/__init__.py +4 -1
  18. meerschaum/utils/packages/_packages.py +6 -6
  19. meerschaum/utils/schedule.py +268 -29
  20. meerschaum/utils/typing.py +1 -1
  21. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dev2.dist-info}/METADATA +12 -14
  22. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dev2.dist-info}/RECORD +28 -28
  23. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dev2.dist-info}/LICENSE +0 -0
  24. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dev2.dist-info}/NOTICE +0 -0
  25. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dev2.dist-info}/WHEEL +0 -0
  26. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dev2.dist-info}/entry_points.txt +0 -0
  27. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dev2.dist-info}/top_level.txt +0 -0
  28. {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dev2.dist-info}/zip-safe +0 -0
@@ -346,6 +346,9 @@ groups['misc'].add_argument(
346
346
  groups['misc'].add_argument(
347
347
  '--nopretty', action="store_true", help="Print elements without 'pretty' formatting"
348
348
  )
349
+ groups['misc'].add_argument(
350
+ '--skip-deps', action="store_true", help="Skip dependencies when installing plugins.",
351
+ )
349
352
  groups['misc'].add_argument(
350
353
  '-P', '--params', type=string_to_dict, help=(
351
354
  "Parameters dictionary in JSON format or simple format. " +
@@ -1,6 +1,7 @@
1
1
  #! /usr/bin/env python
2
2
  # -*- coding: utf-8 -*-
3
3
  # vim:fenc=utf-8
4
+ # type: ignore
4
5
 
5
6
  """
6
7
  The entry point for launching Meerschaum actions.
@@ -61,7 +62,7 @@ def entry_with_args(
61
62
  """
62
63
  import sys
63
64
  from meerschaum.plugins import Plugin
64
- from meerschaum.actions import get_shell, get_action, get_main_action_name
65
+ from meerschaum.actions import get_action, get_main_action_name
65
66
  from meerschaum._internal.arguments import remove_leading_action
66
67
  from meerschaum.utils.venv import Venv, active_venvs, deactivate_venv
67
68
  if kw.get('trace', None):
@@ -54,6 +54,7 @@ def _complete_install(
54
54
  def _install_plugins(
55
55
  action: Optional[List[str]] = None,
56
56
  repository: Optional[str] = None,
57
+ skip_deps: bool = False,
57
58
  force: bool = False,
58
59
  debug: bool = False,
59
60
  **kw: Any
@@ -87,11 +88,14 @@ def _install_plugins(
87
88
 
88
89
  repo_connector = parse_repo_keys(repository)
89
90
 
90
- successes = {}
91
91
  for name in action:
92
92
  info(f"Installing plugin '{name}' from Meerschaum repository '{repo_connector}'...")
93
- success, msg = repo_connector.install_plugin(name, force=force, debug=debug)
94
- successes[name] = (success, msg)
93
+ success, msg = repo_connector.install_plugin(
94
+ name,
95
+ force = force,
96
+ skip_deps = skip_deps,
97
+ debug = debug,
98
+ )
95
99
  print_tuple((success, msg))
96
100
 
97
101
  reload_plugins(debug=debug)
@@ -453,16 +453,20 @@ def _wrap_pipe(
453
453
  return False, msg
454
454
  return True, "Success"
455
455
 
456
- hook_results = []
456
+ pre_hook_results, post_hook_results = [], []
457
457
  def apply_hooks(is_pre_sync: bool):
458
458
  _sync_hooks = (_pre_sync_hooks if is_pre_sync else _post_sync_hooks)
459
+ _hook_results = (pre_hook_results if is_pre_sync else post_hook_results)
459
460
  for module_name, sync_hooks in _sync_hooks.items():
460
461
  plugin_name = module_name.split('.')[-1] if module_name.startswith('plugins.') else None
461
462
  for sync_hook in sync_hooks:
462
463
  hook_result = pool.apply_async(call_sync_hook, (plugin_name, sync_hook))
463
- hook_results.append(hook_result)
464
+ _hook_results.append(hook_result)
464
465
 
465
466
  apply_hooks(True)
467
+ for hook_result in pre_hook_results:
468
+ hook_success, hook_msg = hook_result.get()
469
+ mrsm.pprint((hook_success, hook_msg))
466
470
 
467
471
  try:
468
472
  with Venv(get_connector_plugin(pipe.connector), debug=debug):
@@ -480,7 +484,7 @@ def _wrap_pipe(
480
484
  'sync_complete_timestamp': datetime.now(timezone.utc),
481
485
  })
482
486
  apply_hooks(False)
483
- for hook_result in hook_results:
487
+ for hook_result in post_hook_results:
484
488
  hook_success, hook_msg = hook_result.get()
485
489
  mrsm.pprint((hook_success, hook_msg))
486
490
 
@@ -488,11 +488,11 @@ def update_keys_options(
488
488
  """
489
489
  ctx = dash.callback_context
490
490
  trigger = ctx.triggered[0]['prop_id'].split('.')[0]
491
+ instance_click = trigger == 'instance-select'
491
492
 
492
493
  ### Update the instance first.
493
494
  update_instance_keys = False
494
495
  if not instance_keys:
495
- # instance_keys = get_config('meerschaum', 'web_instance')
496
496
  instance_keys = str(get_api_connector())
497
497
  update_instance_keys = True
498
498
  instance_alerts = []
@@ -516,20 +516,23 @@ def update_keys_options(
516
516
  if location_keys:
517
517
  num_filter += 1
518
518
 
519
- _ck_alone, _mk_alone, _lk_alone = False, False, False
520
- _ck_filter, _mk_filter, _lk_filter = connector_keys, metric_keys, location_keys
521
-
522
- _ck_alone = connector_keys and num_filter == 1
523
- _mk_alone = metric_keys and num_filter == 1
524
- _lk_alone = location_keys and num_filter == 1
519
+ _ck_filter = connector_keys
520
+ _mk_filter = metric_keys
521
+ _lk_filter = location_keys
522
+ _ck_alone = (connector_keys and num_filter == 1) or instance_click
523
+ _mk_alone = (metric_keys and num_filter == 1) or instance_click
524
+ _lk_alone = (location_keys and num_filter == 1) or instance_click
525
525
 
526
526
  from meerschaum.utils import fetch_pipes_keys
527
527
 
528
528
  try:
529
529
  _all_keys = fetch_pipes_keys('registered', get_web_connector(ctx.states))
530
530
  _keys = fetch_pipes_keys(
531
- 'registered', get_web_connector(ctx.states),
532
- connector_keys=_ck_filter, metric_keys=_mk_filter, location_keys=_lk_filter
531
+ 'registered',
532
+ get_web_connector(ctx.states),
533
+ connector_keys = _ck_filter,
534
+ metric_keys = _mk_filter,
535
+ location_keys = _lk_filter,
533
536
  )
534
537
  except Exception as e:
535
538
  instance_alerts += [alert_from_success_tuple((False, str(e)))]
@@ -545,15 +548,39 @@ def update_keys_options(
545
548
  k = locals()[key_type]
546
549
  if k not in _seen_keys[key_type]:
547
550
  _k = 'None' if k in (None, '[None]', 'None', 'null') else k
548
- options.append({'label' : _k, 'value' : _k})
551
+ options.append({'label': _k, 'value': _k})
549
552
  _seen_keys[key_type].add(k)
550
553
 
551
554
  add_options(_connectors_options, _all_keys if _ck_alone else _keys, 'ck')
552
555
  add_options(_metrics_options, _all_keys if _mk_alone else _keys, 'mk')
553
556
  add_options(_locations_options, _all_keys if _lk_alone else _keys, 'lk')
554
- connector_keys = [ck for ck in connector_keys if ck in [_ck['value'] for _ck in _connectors_options]]
555
- metric_keys = [mk for mk in metric_keys if mk in [_mk['value'] for _mk in _metrics_options]]
556
- location_keys = [lk for lk in location_keys if lk in [_lk['value'] for _lk in _locations_options]]
557
+ _connectors_options.sort(key=lambda x: str(x).lower())
558
+ _metrics_options.sort(key=lambda x: str(x).lower())
559
+ _locations_options.sort(key=lambda x: str(x).lower())
560
+ connector_keys = [
561
+ ck
562
+ for ck in connector_keys
563
+ if ck in [
564
+ _ck['value']
565
+ for _ck in _connectors_options
566
+ ]
567
+ ]
568
+ metric_keys = [
569
+ mk
570
+ for mk in metric_keys
571
+ if mk in [
572
+ _mk['value']
573
+ for _mk in _metrics_options
574
+ ]
575
+ ]
576
+ location_keys = [
577
+ lk
578
+ for lk in location_keys
579
+ if lk in [
580
+ _lk['value']
581
+ for _lk in _locations_options
582
+ ]
583
+ ]
557
584
  _connectors_datalist = [html.Option(value=o['value']) for o in _connectors_options]
558
585
  _metrics_datalist = [html.Option(value=o['value']) for o in _metrics_options]
559
586
  _locations_datalist = [html.Option(value=o['value']) for o in _locations_options]
@@ -680,6 +707,9 @@ dash_app.clientside_callback(
680
707
  Input({'type': 'pipe-download-csv-button', 'index': ALL}, 'n_clicks'),
681
708
  )
682
709
  def download_pipe_csv(n_clicks):
710
+ """
711
+ Download the most recent chunk as a CSV file.
712
+ """
683
713
  if not n_clicks:
684
714
  raise PreventUpdate
685
715
  ctx = dash.callback_context.triggered
@@ -818,6 +848,51 @@ def sync_documents_click(n_clicks, sync_editor_text):
818
848
  return alert_from_success_tuple((success, msg))
819
849
 
820
850
 
851
+ dash_app.clientside_callback(
852
+ """
853
+ function(n_clicks_arr, url){
854
+ display_block = {"display": "block"};
855
+
856
+ var clicked = false;
857
+ for (var i = 0; i < n_clicks_arr.length; i++){
858
+ if (n_clicks_arr[i]){
859
+ clicked = true;
860
+ break;
861
+ }
862
+ }
863
+ if (!clicked){ return dash_clientside.no_update; }
864
+
865
+ const triggered_id = dash_clientside.callback_context.triggered_id;
866
+ const action = triggered_id["action"];
867
+ const pipe_meta = JSON.parse(triggered_id["index"]);
868
+
869
+ iframe = document.getElementById('webterm-iframe');
870
+ if (!iframe){ return dash_clientside.no_update; }
871
+ var location = pipe_meta.location;
872
+ if (!pipe_meta.location){
873
+ location = "None";
874
+ }
875
+
876
+ iframe.contentWindow.postMessage(
877
+ {
878
+ action: action,
879
+ subaction: "pipes",
880
+ connector_keys: [pipe_meta.connector],
881
+ metric_keys: [pipe_meta.metric],
882
+ location_keys: [location],
883
+ instance: pipe_meta.instance,
884
+ },
885
+ url
886
+ );
887
+ dash_clientside.set_props("webterm-div", {"style": display_block});
888
+ return [];
889
+ }
890
+ """,
891
+ Output('content-div-right', 'children'),
892
+ Input({'type': 'manage-pipe-button', 'index': ALL, 'action': ALL}, 'n_clicks'),
893
+ State('location', 'href'),
894
+ )
895
+
821
896
  @dash_app.callback(
822
897
  Output("navbar-collapse", "is_open"),
823
898
  [Input("navbar-toggler", "n_clicks")],
@@ -11,6 +11,7 @@ import json
11
11
  import functools
12
12
  import time
13
13
  import traceback
14
+ from datetime import datetime, timezone
14
15
  import meerschaum as mrsm
15
16
  from meerschaum.utils.typing import Optional, Dict, Any
16
17
  from meerschaum.api import get_api_connector, endpoints, CHECK_UPDATE
@@ -54,9 +55,14 @@ def download_job_logs(n_clicks):
54
55
  component_dict = json.loads(ctx[0]['prop_id'].split('.' + 'n_clicks')[0])
55
56
  daemon_id = component_dict['index']
56
57
  daemon = Daemon(daemon_id=daemon_id)
58
+ now = datetime.now(timezone.utc)
59
+ filename = (
60
+ daemon.rotating_log.file_path.name[:(-1 * len('.log'))]
61
+ + '_' + str(int(now.timestamp())) + '.log'
62
+ )
57
63
  return {
58
64
  'content': daemon.log_text,
59
- 'filename': daemon.rotating_log.file_path.name,
65
+ 'filename': filename,
60
66
  }
61
67
 
62
68
 
@@ -74,7 +80,7 @@ def manage_job_button_click(
74
80
  session_data: Optional[Dict[str, Any]] = None,
75
81
  ):
76
82
  """
77
- Start, stop, or pause the given job.
83
+ Start, stop, pause, or delete the given job.
78
84
  """
79
85
  if not n_clicks:
80
86
  raise PreventUpdate
@@ -98,12 +104,18 @@ def manage_job_button_click(
98
104
  component_dict = json.loads(ctx[0]['prop_id'].split('.' + 'n_clicks')[0])
99
105
  daemon_id = component_dict['index']
100
106
  manage_job_action = component_dict['action']
101
- daemon = Daemon(daemon_id=daemon_id)
107
+ try:
108
+ daemon = Daemon(daemon_id=daemon_id)
109
+ except Exception as e:
110
+ daemon = None
111
+ if daemon is None:
112
+ raise PreventUpdate
102
113
 
103
114
  manage_functions = {
104
115
  'start': functools.partial(daemon.run, allow_dirty_run=True),
105
116
  'stop': daemon.quit,
106
117
  'pause': daemon.pause,
118
+ 'delete': daemon.cleanup,
107
119
  }
108
120
  if manage_job_action not in manage_functions:
109
121
  return (
@@ -135,6 +147,46 @@ def manage_job_button_click(
135
147
  build_process_timestamps_children(daemon),
136
148
  )
137
149
 
150
+ dash_app.clientside_callback(
151
+ """
152
+ function(n_clicks_arr, url){
153
+ display_block = {"display": "block"};
154
+
155
+ var clicked = false;
156
+ for (var i = 0; i < n_clicks_arr.length; i++){
157
+ if (n_clicks_arr[i]){
158
+ clicked = true;
159
+ break;
160
+ }
161
+ }
162
+
163
+ if (!clicked){
164
+ return dash_clientside.no_update;
165
+ }
166
+
167
+ const triggered_id = dash_clientside.callback_context.triggered_id;
168
+ const job_daemon_id = triggered_id["index"];
169
+
170
+ iframe = document.getElementById('webterm-iframe');
171
+ if (!iframe){ return dash_clientside.no_update; }
172
+
173
+ iframe.contentWindow.postMessage(
174
+ {
175
+ action: "show",
176
+ subaction: "logs",
177
+ subaction_text: job_daemon_id,
178
+ },
179
+ url
180
+ );
181
+ dash_clientside.set_props("webterm-div", {"style": display_block});
182
+ return [];
183
+ }
184
+ """,
185
+ Output('content-div-right', 'children'),
186
+ Input({'type': 'follow-logs-button', 'index': ALL}, 'n_clicks'),
187
+ State('location', 'href'),
188
+ )
189
+
138
190
 
139
191
  @dash_app.callback(
140
192
  Output({'type': 'manage-job-buttons-div', 'index': ALL}, 'children'),
@@ -49,6 +49,17 @@ def get_jobs_cards(state: WebState):
49
49
  build_process_timestamps_children(d),
50
50
  id = {'type': 'process-timestamps-div', 'index': d.daemon_id},
51
51
  )
52
+ follow_logs_button = dbc.DropdownMenuItem(
53
+ "Follow logs",
54
+ id = {'type': 'follow-logs-button', 'index': d.daemon_id},
55
+ )
56
+ download_logs_button = dbc.DropdownMenuItem(
57
+ "Download logs",
58
+ id = {'type': 'job-download-logs-button', 'index': d.daemon_id},
59
+ )
60
+ logs_menu_children = (
61
+ ([follow_logs_button] if is_authenticated else []) + [download_logs_button]
62
+ )
52
63
  header_children = [
53
64
  html.Div(
54
65
  build_status_children(d),
@@ -56,11 +67,13 @@ def get_jobs_cards(state: WebState):
56
67
  style = {'float': 'left'},
57
68
  ),
58
69
  html.Div(
59
- dbc.Button(
60
- 'Download logs',
61
- size = 'sm',
62
- color = 'link',
63
- id = {'type': 'job-download-logs-button', 'index': d.daemon_id},
70
+ dbc.DropdownMenu(
71
+ logs_menu_children,
72
+ label = "Logs",
73
+ size = "sm",
74
+ align_end = True,
75
+ color = "secondary",
76
+ menu_variant = 'dark',
64
77
  ),
65
78
  style = {'float': 'right'},
66
79
  ),
@@ -74,7 +87,7 @@ def get_jobs_cards(state: WebState):
74
87
  className = "card-text job-card-text",
75
88
  style = {"word-wrap": "break-word"}
76
89
  ),
77
- style={"white-space": "pre-wrap"},
90
+ style = {"white-space": "pre-wrap"},
78
91
  ),
79
92
  html.Div(
80
93
  (
@@ -82,7 +95,7 @@ def get_jobs_cards(state: WebState):
82
95
  if is_authenticated
83
96
  else []
84
97
  ),
85
- id={'type': 'manage-job-buttons-div', 'index': d.daemon_id}
98
+ id = {'type': 'manage-job-buttons-div', 'index': d.daemon_id},
86
99
  ),
87
100
  html.Div(id={'type': 'manage-job-alert-div', 'index': d.daemon_id}),
88
101
  ]
@@ -108,7 +121,7 @@ def build_manage_job_buttons_div_children(daemon: Daemon):
108
121
  return [
109
122
  html.Br(),
110
123
  dbc.Row([
111
- dbc.Col(button, width=4)
124
+ dbc.Col(button, width=6)
112
125
  for button in buttons
113
126
  ])
114
127
  ]
@@ -153,9 +166,22 @@ def build_manage_job_buttons(daemon: Daemon):
153
166
  'index': daemon.daemon_id,
154
167
  },
155
168
  )
169
+ delete_button = dbc.Button(
170
+ 'Delete',
171
+ size = 'sm',
172
+ color = 'danger',
173
+ style = {'width': '100%'},
174
+ id = {
175
+ 'type': 'manage-job-button',
176
+ 'action': 'delete',
177
+ 'index': daemon.daemon_id,
178
+ },
179
+ )
156
180
  buttons = []
157
181
  if daemon.status in ('stopped', 'paused'):
158
182
  buttons.append(start_button)
183
+ if daemon.status == 'stopped':
184
+ buttons.append(delete_button)
159
185
  if daemon.status in ('running',):
160
186
  buttons.append(pause_button)
161
187
  if daemon.status in ('running', 'paused'):
@@ -115,16 +115,77 @@ def get_pipes_cards(*keys, session_data: Optional[Dict[str, Any]] = None):
115
115
  if not isinstance(_pipes, list):
116
116
  _pipes = []
117
117
  for p in _pipes:
118
- footer_children = dbc.Row([
119
- dbc.Col(
120
- dbc.Button(
121
- 'Download recent data',
122
- size = 'sm',
123
- color = 'link',
124
- id = {'type': 'pipe-download-csv-button', 'index': json.dumps(p.meta)},
125
- )
126
- ),
127
- ])
118
+ meta_str = json.dumps(p.meta)
119
+ footer_children = dbc.Row(
120
+ [
121
+ dbc.Col(
122
+ (
123
+ dbc.DropdownMenu(
124
+ label = "Manage",
125
+ children = [
126
+ dbc.DropdownMenuItem(
127
+ 'Delete',
128
+ id = {
129
+ 'type': 'manage-pipe-button',
130
+ 'index': meta_str,
131
+ 'action': 'delete',
132
+ },
133
+ ),
134
+ dbc.DropdownMenuItem(
135
+ 'Drop',
136
+ id = {
137
+ 'type': 'manage-pipe-button',
138
+ 'index': meta_str,
139
+ 'action': 'drop',
140
+ },
141
+ ),
142
+ dbc.DropdownMenuItem(
143
+ 'Clear',
144
+ id = {
145
+ 'type': 'manage-pipe-button',
146
+ 'index': meta_str,
147
+ 'action': 'clear',
148
+ },
149
+ ),
150
+ dbc.DropdownMenuItem(
151
+ 'Verify',
152
+ id = {
153
+ 'type': 'manage-pipe-button',
154
+ 'index': meta_str,
155
+ 'action': 'verify',
156
+ },
157
+ ),
158
+ dbc.DropdownMenuItem(
159
+ 'Sync',
160
+ id = {
161
+ 'type': 'manage-pipe-button',
162
+ 'index': meta_str,
163
+ 'action': 'sync',
164
+ },
165
+ ),
166
+ ],
167
+ direction = "up",
168
+ menu_variant = "dark",
169
+ size = 'sm',
170
+ color = 'secondary',
171
+ )
172
+ ) if authenticated else [],
173
+ width = 2,
174
+ ),
175
+ dbc.Col(width=6),
176
+ dbc.Col(
177
+ dbc.Button(
178
+ 'Download CSV',
179
+ size = 'sm',
180
+ color = 'link',
181
+ style = {'float': 'right'},
182
+ id = {'type': 'pipe-download-csv-button', 'index': meta_str},
183
+ ),
184
+ width = 4,
185
+ ),
186
+ ],
187
+ justify = 'start',
188
+ )
128
189
  card_body_children = [
129
190
  html.H5(
130
191
  html.B(str(p)),
@@ -136,7 +197,7 @@ def get_pipes_cards(*keys, session_data: Optional[Dict[str, Any]] = None):
136
197
  accordion_items_from_pipe(p, authenticated=authenticated),
137
198
  flush = True,
138
199
  start_collapsed = True,
139
- id = {'type': 'pipe-accordion', 'index': json.dumps(p.meta)},
200
+ id = {'type': 'pipe-accordion', 'index': meta_str},
140
201
  )
141
202
  )
142
203
 
@@ -188,14 +249,40 @@ def accordion_items_from_pipe(
188
249
  overview_header = [html.Thead(html.Tr([html.Th("Attribute"), html.Th("Value")]))]
189
250
  dt_name, id_name, val_name = pipe.get_columns('datetime', 'id', 'value', error=False)
190
251
  overview_rows = [
191
- html.Tr([html.Td("Connector"), html.Td(f"{pipe.connector_keys}")]),
192
- html.Tr([html.Td("Metric"), html.Td(f"{pipe.metric_key}")]),
193
- html.Tr([html.Td("Location"), html.Td(f"{pipe.location_key}")]),
194
- html.Tr([html.Td("Instance"), html.Td(f"{pipe.instance_keys}")]),
195
- html.Tr([html.Td("Target Table"), html.Td(f"{pipe.target}")]),
252
+ html.Tr([html.Td("Connector"), html.Td(html.Pre(f"{pipe.connector_keys}"))]),
253
+ html.Tr([html.Td("Metric"), html.Td(html.Pre(f"{pipe.metric_key}"))]),
254
+ html.Tr([html.Td("Location"), html.Td(html.Pre(f"{pipe.location_key}"))]),
255
+ html.Tr([html.Td("Instance"), html.Td(html.Pre(f"{pipe.instance_keys}"))]),
256
+ html.Tr([html.Td("Target Table"), html.Td(html.Pre(f"{pipe.target}"))]),
196
257
  ]
197
- for col_key, col in pipe.columns.items():
198
- overview_rows.append(html.Tr([html.Td(f"'{col_key}' Index"), html.Td(col)]))
258
+ columns = pipe.columns.copy()
259
+ if columns:
260
+ datetime_index = columns.pop('datetime', None)
261
+ columns_items = []
262
+ if datetime_index:
263
+ columns_items.append(html.Li(f"{datetime_index} (datetime)"))
264
+ columns_items.extend([
265
+ html.Li(f"{col}")
266
+ for col_key, col in columns.items()
267
+ ])
268
+ overview_rows.append(
269
+ html.Tr([
270
+ html.Td("Indices" if len(columns_items) != 1 else "Index"),
271
+ html.Td(html.Pre(html.Ul(columns_items))),
272
+ ])
273
+ )
274
+ tags = pipe.tags
275
+ if tags:
276
+ tags_items = html.Ul([
277
+ html.Li(tag)
278
+ for tag in tags
279
+ ])
280
+ overview_rows.append(
281
+ html.Tr([
282
+ html.Td("Tags"),
283
+ html.Td(html.Pre(tags_items)),
284
+ ])
285
+ )
199
286
 
200
287
  items_bodies['overview'] = dbc.Table(
201
288
  overview_header + [html.Tbody(overview_rows)],