Skip to content

Commit 209297b

Browse files
committed
concore_cli: add watch command for live simulation monitoring
1 parent cc6ba38 commit 209297b

3 files changed

Lines changed: 240 additions & 1 deletion

File tree

concore_cli/cli.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .commands.status import show_status
1010
from .commands.stop import stop_all
1111
from .commands.inspect import inspect_workflow
12+
from .commands.watch import watch_study
1213

1314
console = Console()
1415
DEFAULT_EXEC_TYPE = 'windows' if os.name == 'nt' else 'posix'
@@ -87,5 +88,17 @@ def stop():
8788
console.print(f"[red]Error:[/red] {str(e)}")
8889
sys.exit(1)
8990

91+
@cli.command()
92+
@click.argument('study_dir', type=click.Path(exists=True))
93+
@click.option('--interval', '-n', default=2.0, help='Refresh interval in seconds')
94+
@click.option('--once', is_flag=True, help='Print a single snapshot and exit')
95+
def watch(study_dir, interval, once):
96+
"""Watch a running simulation study for live monitoring"""
97+
try:
98+
watch_study(study_dir, interval, once, console)
99+
except Exception as e:
100+
console.print(f"[red]Error:[/red] {str(e)}")
101+
sys.exit(1)
102+
90103
if __name__ == '__main__':
91104
cli()

concore_cli/commands/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
from .validate import validate_workflow
44
from .status import show_status
55
from .stop import stop_all
6+
from .watch import watch_study
67

7-
__all__ = ['init_project', 'run_workflow', 'validate_workflow', 'show_status', 'stop_all']
8+
__all__ = ['init_project', 'run_workflow', 'validate_workflow', 'show_status', 'stop_all', 'watch_study']

concore_cli/commands/watch.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import time
2+
import re
3+
from pathlib import Path
4+
from ast import literal_eval
5+
from datetime import datetime
6+
from rich.table import Table
7+
from rich.live import Live
8+
from rich.panel import Panel
9+
from rich.text import Text
10+
from rich.console import Group
11+
12+
13+
def watch_study(study_dir, interval, once, console):
14+
study_path = Path(study_dir).resolve()
15+
if not study_path.is_dir():
16+
console.print(f"[red]Error:[/red] '{study_dir}' is not a directory")
17+
return
18+
19+
nodes = _find_nodes(study_path)
20+
edges = _find_edges(study_path, nodes)
21+
22+
if not nodes and not edges:
23+
console.print(Panel(
24+
"[yellow]No nodes or edge directories found.[/yellow]\n"
25+
"[dim]Make sure you point to a built study directory (run makestudy/build first).[/dim]",
26+
title="concore watch", border_style="yellow"))
27+
return
28+
29+
if once:
30+
output = _build_display(study_path, nodes, edges)
31+
console.print(output)
32+
return
33+
34+
console.print(f"[cyan]Watching:[/cyan] {study_path}")
35+
console.print(f"[dim]Refresh every {interval}s — Ctrl+C to stop[/dim]\n")
36+
37+
try:
38+
with Live(console=console, refresh_per_second=1, screen=False) as live:
39+
while True:
40+
nodes = _find_nodes(study_path)
41+
edges = _find_edges(study_path, nodes)
42+
live.update(_build_display(study_path, nodes, edges))
43+
time.sleep(interval)
44+
except KeyboardInterrupt:
45+
console.print("\n[yellow]Watch stopped.[/yellow]")
46+
47+
48+
def _build_display(study_path, nodes, edges):
49+
parts = []
50+
51+
header = Text()
52+
header.append("Study ", style="bold cyan")
53+
header.append(study_path.name, style="bold white")
54+
header.append(f" | {len(nodes)} node(s), {len(edges)} edge(s)", style="dim")
55+
parts.append(header)
56+
57+
if edges:
58+
parts.append(Text())
59+
parts.append(_edge_table(edges))
60+
61+
if nodes:
62+
parts.append(Text())
63+
parts.append(_node_table(nodes))
64+
65+
if not edges and not nodes:
66+
parts.append(Panel("[yellow]No data yet[/yellow]",
67+
border_style="yellow"))
68+
69+
return Group(*parts)
70+
71+
72+
def _edge_table(edges):
73+
table = Table(title="Edges (port data)", show_header=True,
74+
title_style="bold cyan", expand=True)
75+
table.add_column("Edge", style="green", min_width=10)
76+
table.add_column("Port File", style="cyan")
77+
table.add_column("Simtime", style="yellow", justify="right")
78+
table.add_column("Value", style="white")
79+
table.add_column("Age", style="magenta", justify="right")
80+
81+
now = time.time()
82+
for edge_name, edge_path in sorted(edges):
83+
files = _read_edge_files(edge_path)
84+
if not files:
85+
table.add_row(edge_name, "[dim]—[/dim]", "", "[dim]empty[/dim]", "")
86+
continue
87+
first = True
88+
for fname, simtime_val, value_str, mtime in files:
89+
age_str = _format_age(now, mtime)
90+
label = edge_name if first else ""
91+
st = str(simtime_val) if simtime_val is not None else "—"
92+
table.add_row(label, fname, st, value_str, age_str)
93+
first = False
94+
95+
return table
96+
97+
98+
def _node_table(nodes):
99+
table = Table(title="Nodes", show_header=True,
100+
title_style="bold cyan", expand=True)
101+
table.add_column("Node", style="green", min_width=10)
102+
table.add_column("Ports (in)", style="cyan")
103+
table.add_column("Ports (out)", style="cyan")
104+
table.add_column("Source", style="dim")
105+
106+
for node_name, node_path in sorted(nodes):
107+
in_dirs = sorted(d.name for d in node_path.iterdir()
108+
if d.is_dir() and re.match(r'^in\d+$', d.name))
109+
out_dirs = sorted(d.name for d in node_path.iterdir()
110+
if d.is_dir() and re.match(r'^out\d+$', d.name))
111+
src = _detect_source(node_path)
112+
table.add_row(
113+
node_name,
114+
", ".join(in_dirs) if in_dirs else "—",
115+
", ".join(out_dirs) if out_dirs else "—",
116+
src,
117+
)
118+
119+
return table
120+
121+
122+
def _find_nodes(study_path):
123+
nodes = []
124+
port_re = re.compile(r'^(in|out)\d+$')
125+
skip = {'src', '__pycache__', '.git'}
126+
for entry in study_path.iterdir():
127+
if not entry.is_dir() or entry.name in skip or entry.name.startswith('.'):
128+
continue
129+
try:
130+
has_ports = any(c.is_dir() and port_re.match(c.name)
131+
for c in entry.iterdir())
132+
except PermissionError:
133+
continue
134+
if has_ports:
135+
nodes.append((entry.name, entry))
136+
return nodes
137+
138+
139+
def _find_edges(study_path, nodes=None):
140+
if nodes is None:
141+
nodes = _find_nodes(study_path)
142+
node_names = {name for name, _ in nodes}
143+
skip = {'src', '__pycache__', '.git'}
144+
edges = []
145+
for entry in study_path.iterdir():
146+
if not entry.is_dir():
147+
continue
148+
if entry.name in skip or entry.name in node_names or entry.name.startswith('.'):
149+
continue
150+
try:
151+
has_file = any(f.is_file() for f in entry.iterdir())
152+
except PermissionError:
153+
continue
154+
if has_file:
155+
edges.append((entry.name, entry))
156+
return edges
157+
158+
159+
def _read_edge_files(edge_path):
160+
results = []
161+
try:
162+
children = sorted(edge_path.iterdir())
163+
except PermissionError:
164+
return results
165+
for f in children:
166+
if not f.is_file():
167+
continue
168+
# skip concore internal files
169+
if f.name.startswith('concore.'):
170+
continue
171+
simtime_val, value_str = _parse_port_file(f)
172+
try:
173+
mtime = f.stat().st_mtime
174+
except OSError:
175+
mtime = 0
176+
results.append((f.name, simtime_val, value_str, mtime))
177+
return results
178+
179+
180+
def _detect_source(node_path):
181+
for ext in ('*.py', '*.m', '*.cpp', '*.v', '*.sh', '*.java'):
182+
matches = list(node_path.glob(ext))
183+
for m in matches:
184+
# skip concore library copies
185+
if m.name.startswith('concore'):
186+
continue
187+
return m.name
188+
return "—"
189+
190+
191+
def _parse_port_file(port_file):
192+
try:
193+
content = port_file.read_text().strip()
194+
if not content:
195+
return None, "(empty)"
196+
val = literal_eval(content)
197+
if isinstance(val, list) and len(val) > 0:
198+
simtime = val[0] if isinstance(val[0], (int, float)) else None
199+
data = val[1:] if simtime is not None else val
200+
data_str = str(data)
201+
if len(data_str) > 50:
202+
data_str = data_str[:47] + "..."
203+
return simtime, data_str
204+
raw = str(val)
205+
return None, raw[:50] if len(raw) > 50 else raw
206+
except Exception:
207+
try:
208+
raw = port_file.read_text().strip()
209+
return None, raw[:50] if raw else "(empty)"
210+
except Exception:
211+
return None, "(read error)"
212+
213+
214+
def _format_age(now, mtime):
215+
if mtime == 0:
216+
return "—"
217+
age = now - mtime
218+
if age < 3:
219+
return "[bold green]now[/bold green]"
220+
elif age < 60:
221+
return f"{int(age)}s"
222+
elif age < 3600:
223+
return f"{int(age // 60)}m"
224+
else:
225+
return datetime.fromtimestamp(mtime).strftime("%H:%M:%S")

0 commit comments

Comments
 (0)