Skip to content

Commit 15da729

Browse files
Directory and file viewing in the frontend.
Enabled viewing the directory structure of any folder in the repo, defined relative to its root, inside the frontend. Improved the UI for the file viewer, directory viewer, and the homepage.
1 parent 9b8af18 commit 15da729

16 files changed

Lines changed: 317 additions & 28 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""A package for viewing directory contents on the backend, used by the frontend for visualization."""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""A package for dir-viewing utilities, used by the dir_viewer router to visualize directory contents on the frontend."""
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""A utility function to build a tree representation of a directory structure."""
2+
from pathlib import Path
3+
4+
5+
def build_tree(path: Path):
6+
"""
7+
Recursively build a tree of the folder as a dict.
8+
Files are listed with None values, directories as nested dicts.
9+
"""
10+
tree = {}
11+
try:
12+
for p in sorted(path.iterdir()):
13+
if p.is_dir():
14+
tree[p.name] = build_tree(p)
15+
else:
16+
tree[p.name] = None
17+
return tree
18+
except PermissionError:
19+
return {"error": "Permission denied"}

ml_service/backend/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from ml_service.backend.limiter import limiter
1111
from ml_service.backend.routers.data import router as data_router
12+
from ml_service.backend.routers.dir_viewer import router as dir_viewer_router
1213
from ml_service.backend.routers.features import router as features_router
1314
from ml_service.backend.routers.modeling import router as modeling_router
1415
from ml_service.backend.routers.pipeline_cfg import router as pipeline_cfg_router
@@ -31,7 +32,7 @@
3132
allow_headers=["*"],
3233
)
3334

34-
routers = [pipelines_router, modeling_router, features_router, data_router, pipeline_cfg_router, promotion_thresholds_router, scripts_router, viewer_router]
35+
routers = [pipelines_router, modeling_router, features_router, data_router, pipeline_cfg_router, promotion_thresholds_router, scripts_router, viewer_router, dir_viewer_router]
3536

3637
for router in routers:
3738
app.include_router(router)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""A FastAPI router for viewing directory structure on the backend, used by the frontend for visualization."""
2+
import os
3+
from pathlib import Path
4+
5+
import yaml
6+
from fastapi import APIRouter, HTTPException, Request
7+
8+
from ml_service.backend.dir_viewer.utils.build_tree import build_tree
9+
from ml_service.backend.main import limiter
10+
11+
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
12+
13+
router = APIRouter(prefix="/dir_viewer", tags=["dir_viewer"])
14+
15+
@router.post("/load", status_code=200)
16+
@limiter.limit("10/minute")
17+
def load_dir(payload: dict, request: Request):
18+
"""
19+
payload: {"path": "<relative path from repo root>"}
20+
Returns folder tree as YAML string.
21+
"""
22+
path_str = payload.get("path")
23+
if not path_str:
24+
raise HTTPException(status_code=400, detail="Missing 'path' in payload")
25+
26+
# Make the path relative to repo root
27+
path = (Path(repo_root) / path_str).resolve()
28+
29+
# Safety: prevent access outside repo
30+
try:
31+
path.relative_to(repo_root)
32+
except ValueError:
33+
raise HTTPException(status_code=403, detail="Cannot access directories outside repo root") from None
34+
35+
if not path.exists() or not path.is_dir():
36+
raise HTTPException(status_code=404, detail=f"Directory not found: {path}")
37+
38+
tree = build_tree(path)
39+
40+
# Convert tree to YAML for frontend display
41+
tree_yaml = yaml.safe_dump(tree, sort_keys=False)
42+
43+
return {"tree": tree, "tree_yaml": tree_yaml, "path": str(path)}

ml_service/backend/routers/viewer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
"""A FastAPI router for viewing file contents on the backend, used by the frontend for visualization."""
12
from pathlib import Path
23

34
import yaml

ml_service/frontend/app.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from ml_service.frontend.configs.pipeline_cfg.page import register as pipeline_cfg_register
1919
from ml_service.frontend.configs.promotion_thresholds.page import get_layout as promotion_layout
2020
from ml_service.frontend.configs.promotion_thresholds.page import register as promotion_register
21+
from ml_service.frontend.dir_viewer.page import get_layout as dir_viewer_layout
22+
from ml_service.frontend.dir_viewer.page import register as dir_viewer_register
2123
from ml_service.frontend.docs.page import get_layout as docs_layout
2224
from ml_service.frontend.docs.page import register as docs_register
2325
from ml_service.frontend.pipelines.page import get_layout as pipelines_layout
@@ -47,7 +49,8 @@
4749
"Pipelines": pipelines_layout,
4850
"Scripts": scripts_layout,
4951
"Docs": docs_layout,
50-
"Viewer": viewer_layout,
52+
"File Viewer": viewer_layout,
53+
"Directory Viewer": dir_viewer_layout,
5154
}
5255

5356
# Register page callbacks
@@ -61,6 +64,7 @@
6164
scripts_register,
6265
docs_register,
6366
viewer_register,
67+
dir_viewer_register,
6468
]:
6569
register_func(app)
6670

@@ -145,12 +149,29 @@ def home_layout() -> dbc.Container:
145149
html.Li("Some md elements may render oddly, but all content should be there. Read directly from the md file if something looks off."),
146150
])
147151
], style={"marginBottom": "1rem"}),
152+
html.Li([
153+
html.Strong("File Viewer:"),
154+
html.Ul([
155+
html.Li("View contents of files in the repository."),
156+
html.Li("Supports .yaml and .json files."),
157+
html.Li("Useful for quickly checking .yaml and .json files without leaving the dashboard."),
158+
])
159+
], style={"marginBottom": "1rem"}),
160+
html.Li([
161+
html.Strong("Directory Viewer:"),
162+
html.Ul([
163+
html.Li("View directory structure from a chosen directory in the repository."),
164+
html.Li("Specify the directory to view by entering a path relative to the repository root (e.g. 'configs/data')."),
165+
html.Li("Input box allows to write and copy paths without leaving the page."),
166+
])
167+
], style={"marginBottom": "1rem"}),
148168
],
149169
style={
150170
"fontSize": "1.2rem",
151171
"paddingTop": "5rem",
152172
"textAlign": "left",
153173
"marginLeft": "10%",
174+
"marginBottom": "5rem",
154175
}
155176
)
156177
],
@@ -175,7 +196,8 @@ def generate_page_links():
175196
"Pipelines": "play-circle",
176197
"Scripts": "terminal",
177198
"Docs": "book",
178-
"Viewer": "eye",
199+
"File Viewer": "eye",
200+
"Directory Viewer": "folder",
179201
}
180202
links = []
181203
for name in PAGES:
@@ -205,7 +227,14 @@ def generate_page_links():
205227
),
206228

207229
# Sidebar toggle button (hamburger icon)
208-
dbc.Button("☰", id="sidebar-toggle", color="primary", className="mb-3", n_clicks=0, style={"width": "100%"}),
230+
dbc.Button(
231+
"☰",
232+
id="sidebar-toggle",
233+
color="primary",
234+
className="mb-3",
235+
n_clicks=0,
236+
style={"width": "100%"}
237+
),
209238

210239
# Collapsible sidebar with page links
211240
dbc.Collapse(
@@ -222,11 +251,14 @@ def generate_page_links():
222251
style={"position": "sticky", "marginTop": "5rem"}
223252
),
224253
dbc.Col(
225-
html.Div(id="page-content-container", style={
226-
"padding": "20px",
227-
"height": "98vh",
228-
"overflowY": "auto",
229-
}),
254+
html.Div(
255+
id="page-content-container",
256+
style={
257+
"padding": "20px",
258+
"height": "98vh",
259+
"overflowY": "auto",
260+
}
261+
),
230262
width=10,
231263
)
232264
],
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""A package for viewing directory contents on the frontend."""
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import os
2+
3+
import dash_bootstrap_components as dbc
4+
import requests
5+
from dash import Input, Output, State
6+
7+
PAGE_PREFIX = "/dir_viewer"
8+
API_URL = os.getenv("ML_SERVICE_BACKEND_URL", "http://localhost:8000")
9+
10+
def register_callbacks(app):
11+
@app.callback(
12+
Output(f"{PAGE_PREFIX}-viewer", "value"),
13+
Output(f"{PAGE_PREFIX}-viewer", "mode"),
14+
Output(f"{PAGE_PREFIX}-status", "children"),
15+
Input(f"{PAGE_PREFIX}-load-btn", "n_clicks"),
16+
State(f"{PAGE_PREFIX}-path-input", "value"),
17+
prevent_initial_call=True,
18+
)
19+
def load_dir(_, path_str):
20+
if not path_str:
21+
return "", "yaml", dbc.Alert("Path required", color="danger")
22+
23+
try:
24+
r = requests.post(f"{API_URL}/dir_viewer/load", json={"path": path_str}, timeout=10)
25+
except Exception as e:
26+
return "", "yaml", dbc.Alert(f"Backend unreachable: {e}", color="danger")
27+
28+
if not r.ok:
29+
return "", "yaml", dbc.Alert(f"{r.status_code}: {r.text}", color="danger")
30+
31+
result = r.json()
32+
return result["tree_yaml"], "yaml", dbc.Alert(f"Loaded directory tree for {result['path']}", color="success")
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import dash_ace
2+
import dash_bootstrap_components as dbc
3+
from dash import html
4+
5+
PAGE_PREFIX = "/dir_viewer"
6+
7+
def build_layout():
8+
return dbc.Container(
9+
[
10+
dbc.Col(
11+
[
12+
html.H1(
13+
"Directory Viewer",
14+
style={
15+
"textAlign": "center"
16+
}
17+
),
18+
19+
dbc.Label(
20+
"Enter directory path relative to repo root:",
21+
html_for=f"{PAGE_PREFIX}-path-input",
22+
style={
23+
"fontSize": "1.2rem"
24+
}
25+
),
26+
27+
dbc.Input(
28+
id=f"{PAGE_PREFIX}-path-input",
29+
placeholder="e.g. experiments",
30+
type="text",
31+
style={
32+
"fontSize": "1.2rem"
33+
},
34+
),
35+
36+
dbc.Button(
37+
"Load",
38+
id=f"{PAGE_PREFIX}-load-btn",
39+
color="primary",
40+
style={
41+
"width": "120px"
42+
},
43+
),
44+
45+
html.Div(id=f"{PAGE_PREFIX}-status"),
46+
47+
dash_ace.DashAceEditor(
48+
id=f"{PAGE_PREFIX}-viewer",
49+
mode="yaml",
50+
theme="github",
51+
value="",
52+
readOnly=True,
53+
fontSize=16,
54+
height="700px",
55+
setOptions={
56+
"showLineNumbers":
57+
True
58+
},
59+
placeholder="Directory tree will be displayed here in YAML format after loading.",
60+
),
61+
62+
# Manual path input + prompt
63+
dbc.Row(
64+
[
65+
dbc.Input(
66+
id=f"{PAGE_PREFIX}-manual-path",
67+
placeholder="Enter or paste directory path here...",
68+
type="text",
69+
style={
70+
"fontSize": "1.1rem",
71+
"width": "800px"
72+
},
73+
),
74+
],
75+
),
76+
77+
html.P(
78+
[
79+
"The input field above is for convenience - it allows you to quickly write the path into it, without leaving the page, which you can then copy-paste into the ",
80+
html.A(
81+
"file viewer",
82+
href="/File_Viewer",
83+
style={
84+
"fontWeight": "bold",
85+
"color": "#007bff",
86+
"textDecoration": "underline"
87+
}
88+
),
89+
" to view the contents of a yaml or json file. Ensure that the path is relative to the repo root, and that the file exists.",
90+
],
91+
style={
92+
"width": "800px"
93+
}
94+
),
95+
],
96+
style={
97+
"alignItems": "center",
98+
"display": "flex",
99+
"flexDirection": "column",
100+
"width": "50%",
101+
"margin": "0 auto",
102+
"gap": "1rem"
103+
}
104+
),
105+
],
106+
fluid=True,
107+
style={
108+
"backgroundColor": "#8fa0d8",
109+
"minHeight": "100%",
110+
"paddingTop": "45px",
111+
"paddingBottom": "50px",
112+
},
113+
)
114+

0 commit comments

Comments
 (0)