Skip to content

Commit 17f4737

Browse files
committed
Update doc + improve security (remove julia code evaluation from remotes by default)
1 parent 929eabc commit 17f4737

14 files changed

Lines changed: 164 additions & 69 deletions

File tree

.github/workflows/CI.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ jobs:
111111
- name: Build graph editor frontend
112112
working-directory: frontend
113113
run: npm run build
114+
- name: Check committed graph editor assets
115+
run: |
116+
git diff --exit-code -- frontend/dist || {
117+
echo "frontend/dist is stale. Run npm run build in frontend/ and commit the rebuilt assets.";
118+
exit 1;
119+
}
114120
- name: Install Playwright browser
115121
working-directory: frontend
116122
run: npx playwright install --with-deps chromium

docs/src/step_by_step/graph_visualization_editor.md

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The thermal time model computes `TT_cu`, the LAI model consumes `TT_cu` and comp
3131

3232
```@raw html
3333
<iframe
34-
src="../www/simple_dependency_graph.html"
34+
src="../../www/simple_dependency_graph.html"
3535
style="width: 100%; height: 720px; border: 1px solid #d8cfc2; border-radius: 8px; background: #f7f0e7;"
3636
title="PlantSimEngine dependency graph example"
3737
></iframe>
@@ -54,44 +54,18 @@ write_graph_view("dependency_graph.html", mapping)
5454

5555
The returned file path is absolute, so you can print it, open it in a browser, or embed it in another documentation site.
5656

57-
## Embedding a graph in package documentation
58-
59-
For package documentation built with Documenter, generate the HTML file before `makedocs` and place it somewhere under `docs/src`, for example `docs/src/www/model_graph.html`:
60-
61-
```julia
62-
# docs/make.jl
63-
using Documenter
64-
using PlantSimEngine
65-
using YourPackage
66-
67-
mapping = YourPackage.default_mapping()
68-
write_graph_view(joinpath(@__DIR__, "src", "www", "model_graph.html"), mapping)
57+
## Interactive editor
6958

70-
makedocs(;
71-
# ...
72-
)
73-
```
59+
The interactive editor uses the same graph JSON as the static viewer, but it keeps a WebSocket connection open to Julia. Julia remains the source of truth: the browser sends edit commands, Julia applies them to the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser.
7460

75-
Then embed it from a markdown page:
61+
The editor is implemented as a package extension. Static graph files do not need `HTTP`, but the live editor does. In a project that only depends on `PlantSimEngine`, install `HTTP` first:
7662

77-
```html
78-
<iframe
79-
src="../www/model_graph.html"
80-
style="width: 100%; height: 720px; border: 1px solid #d8cfc2; border-radius: 8px;"
81-
title="Model dependency graph"
82-
></iframe>
63+
```julia
64+
using Pkg
65+
Pkg.add("HTTP")
8366
```
8467

85-
Use the right relative path for the page where the iframe lives. A page in `docs/src/multiscale/` usually needs `../www/model_graph.html`; a page at the root of `docs/src/` usually needs `www/model_graph.html`.
86-
87-
!!! tip
88-
This is the same pattern used to show large package mappings, such as the XPalm dependency graph, directly inside package documentation. The viewer is static, so it works on GitHub Pages without a Julia server.
89-
90-
## Interactive editor
91-
92-
The interactive editor uses the same graph JSON as the static viewer, but it keeps a WebSocket connection open to Julia. Julia remains the source of truth: the browser sends edit commands, Julia applies them to the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser.
93-
94-
The editor is implemented as a package extension. Load `HTTP` before calling [`edit_graph`](@ref):
68+
Then load `HTTP` before calling [`edit_graph`](@ref):
9569

9670
```julia
9771
using PlantSimEngine
@@ -121,7 +95,7 @@ By default, `edit_graph` opens `session.url` in the system default browser. Pass
12195
session = edit_graph(mapping; open_browser=false)
12296
```
12397

124-
The URL contains a session token and the server listens on `127.0.0.1` by default. Treat that URL as a local capability: anyone who can reach it can edit the live mapping. If you intentionally bind to another host, pass `allow_remote=true` only on a trusted network.
98+
The URL contains a session token and the server listens on `127.0.0.1` by default. Treat that URL as a local capability: anyone who can reach it can edit the live mapping. If you intentionally bind to another host, pass `allow_remote=true` only on a trusted network. Raw `julia` parameter values are disabled by default for remote sessions; pass `allow_julia_eval=true` only if you explicitly accept that risk.
12599

126100
To stop the HTTP/WebSocket session, run:
127101

@@ -227,3 +201,36 @@ available_models(:light_interception)
227201
```
228202

229203
If a package is not loaded with `using PackageName`, its model types are not present in the Julia session and the editor cannot list them.
204+
205+
## Embedding a graph in package documentation
206+
207+
For package documentation built with Documenter, generate the HTML file before `makedocs` and place it somewhere under `docs/src`, for example `docs/src/www/model_graph.html`:
208+
209+
```julia
210+
# docs/make.jl
211+
using Documenter
212+
using PlantSimEngine
213+
using YourPackage
214+
215+
mapping = YourPackage.default_mapping()
216+
write_graph_view(joinpath(@__DIR__, "src", "www", "model_graph.html"), mapping)
217+
218+
makedocs(;
219+
# ...
220+
)
221+
```
222+
223+
Then embed it from a markdown page:
224+
225+
```html
226+
<iframe
227+
src="../../www/model_graph.html"
228+
style="width: 100%; height: 720px; border: 1px solid #d8cfc2; border-radius: 8px;"
229+
title="Model dependency graph"
230+
></iframe>
231+
```
232+
233+
Use the right relative path for the page where the iframe lives and remember that Documenter deploys pretty URLs by default. A page in `docs/src/multiscale/page.md` usually needs `../../www/model_graph.html`; a page at the root of `docs/src/` usually needs `www/model_graph.html`.
234+
235+
!!! tip
236+
This is the same pattern used to show large package mappings, such as the XPalm dependency graph, directly inside package documentation. The viewer is static, so it works on GitHub Pages without a Julia server.

ext/PlantSimEngineGraphEditorExt.jl

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ mutable struct GraphEditorSession{M,G,S} <: PlantSimEngine.AbstractGraphEditorSe
2222
last_autosaved_path::Union{Nothing,String}
2323
recent_file_path::String
2424
recent_mapping_paths::Vector{String}
25+
allow_julia_eval::Bool
2526
end
2627

2728
current_mapping(session::GraphEditorSession) = session.mapping
@@ -48,7 +49,7 @@ end
4849
current_mapping_code(session::GraphEditorSession) = _model_mapping_to_julia(session.mapping)
4950

5051
"""
51-
edit_graph([mapping]; mtg=nothing, host="127.0.0.1", port=8765, open_browser=true, autosave=true, allow_remote=false)
52+
edit_graph([mapping]; mtg=nothing, host="127.0.0.1", port=8765, open_browser=true, autosave=true, allow_remote=false, allow_julia_eval=nothing)
5253
5354
Start a local graph editor session. The returned session owns the current
5455
`ModelMapping`; call `current_mapping(session)` to recover the edited mapping.
@@ -59,6 +60,8 @@ By default, the session URL is opened with the system default browser. Pass
5960
`open_browser=false` to disable this, for example in scripts or tests.
6061
The URL includes a session token and the server is restricted to localhost
6162
unless `allow_remote=true` is passed explicitly.
63+
Raw `julia` parameter values are disabled by default for remote sessions; pass
64+
`allow_julia_eval=true` only for trusted sessions.
6265
When `autosave=true`, a recovery script is written to the temporary directory.
6366
After saving through the web editor, every successful graph edit, undo, redo,
6467
or recent-file load rewrites the saved Julia script.
@@ -76,6 +79,7 @@ function edit_graph(
7679
autosave_path::Union{Nothing,AbstractString}=nothing,
7780
recent_file_path::Union{Nothing,AbstractString}=nothing,
7881
allow_remote::Bool=false,
82+
allow_julia_eval::Union{Nothing,Bool}=nothing,
7983
)
8084
if !_is_loopback_host(host) && !allow_remote
8185
error("Graph editor sessions are limited to localhost by default. Pass `allow_remote=true` only for a trusted network environment.")
@@ -89,6 +93,7 @@ function edit_graph(
8993
server = HTTP.listen!(handler, host, port; listenany=true, verbose=false)
9094
actual_port = HTTP.port(server)
9195
token = _session_token()
96+
resolved_allow_julia_eval = isnothing(allow_julia_eval) ? !allow_remote : allow_julia_eval
9297
session = GraphEditorSession(
9398
mapping,
9499
mtg,
@@ -105,6 +110,7 @@ function edit_graph(
105110
nothing,
106111
_normalized_output_path(isnothing(recent_file_path) ? _default_recent_file_path() : recent_file_path),
107112
_load_recent_mapping_paths(isnothing(recent_file_path) ? _default_recent_file_path() : recent_file_path),
113+
resolved_allow_julia_eval,
108114
)
109115
session_ref[] = session
110116
_persist_session_mapping!(session; write_save_target=false)
@@ -291,7 +297,7 @@ function _handle_command!(session::GraphEditorSession, command)
291297
redo!(session)
292298
persist = true
293299
elseif action == "edit"
294-
edit = _edit_from_command(command)
300+
edit = _edit_from_command(session, command)
295301
apply_edit!(session, edit)
296302
persist = true
297303
elseif action == "write_mapping_code"
@@ -311,7 +317,7 @@ function _handle_command!(session::GraphEditorSession, command)
311317
end
312318
end
313319

314-
function _edit_from_command(command)
320+
function _edit_from_command(session::GraphEditorSession, command)
315321
kind = get(command, "kind", "")
316322
kind == "mark_previous_timestep" && return PlantSimEngine.MarkPreviousTimeStep(
317323
Symbol(command["scale"]),
@@ -329,7 +335,7 @@ function _edit_from_command(command)
329335
)
330336
if kind == "update_model"
331337
model_type = _resolve_model_type(command["modelType"])
332-
parameters = _parameters_from_command(get(command, "parameters", Dict()))
338+
parameters = _parameters_from_command(session, get(command, "parameters", Dict()))
333339
timestep = _timestep_from_command(get(command, "timestep", nothing); default_sentinel=true)
334340
return PlantSimEngine.UpdateModel(
335341
Symbol(command["scale"]),
@@ -352,11 +358,11 @@ function _edit_from_command(command)
352358
kind == "set_initialization" && return PlantSimEngine.SetStatusVariable(
353359
Symbol(command["scale"]),
354360
Symbol(command["variable"]),
355-
_parse_parameter_value(get(command, "value", Dict("type" => "julia", "value" => "nothing"))),
361+
_parse_parameter_value(session, get(command, "value", Dict("type" => "julia", "value" => "nothing"))),
356362
)
357363
if kind in ("add_model", "replace_model")
358364
model_type = _resolve_model_type(command["modelType"])
359-
parameters = _parameters_from_command(get(command, "parameters", Dict()))
365+
parameters = _parameters_from_command(session, get(command, "parameters", Dict()))
360366
timestep = _timestep_from_command(get(command, "timestep", nothing))
361367
if kind == "add_model"
362368
return PlantSimEngine.AddModel(Symbol(command["scale"]), model_type, parameters, timestep)
@@ -388,15 +394,15 @@ function _resolve_model_type(label)
388394
error("No loaded PlantSimEngine model type matches `$label`. Load the package that defines it first.")
389395
end
390396

391-
function _parameters_from_command(parameters)
397+
function _parameters_from_command(session::GraphEditorSession, parameters)
392398
pairs = Pair{Symbol,Any}[]
393399
for (key, value) in parameters
394-
push!(pairs, Symbol(key) => _parse_parameter_value(value))
400+
push!(pairs, Symbol(key) => _parse_parameter_value(session, value))
395401
end
396402
return (; pairs...)
397403
end
398404

399-
function _parse_parameter_value(value)
405+
function _parse_parameter_value(session::GraphEditorSession, value)
400406
value isa AbstractDict || return value
401407
choice = Symbol(get(value, "type", "julia"))
402408
raw = get(value, "value", nothing)
@@ -406,6 +412,7 @@ function _parse_parameter_value(value)
406412
choice == :symbol && return Symbol(raw)
407413
choice == :string && return String(raw)
408414
choice == :nothing && return nothing
415+
choice == :julia && !session.allow_julia_eval && error("Raw Julia parameter values are disabled for this graph editor session.")
409416
choice == :julia && return Core.eval(Main, Meta.parse(String(raw)))
410417
return raw
411418
end
@@ -689,7 +696,7 @@ function _model_mapping_to_julia(mapping::PlantSimEngine.ModelMapping)
689696
required_status_variables = _required_status_variables(mapping)
690697
println(io, "mapping = ModelMapping(")
691698
for scale in keys(mapping)
692-
println(io, " :$(scale) => (")
699+
println(io, " $(_symbol_code(scale)) => (")
693700
items = _scale_items(mapping[scale])
694701
required = get(required_status_variables, scale, Set{Symbol}())
695702
for item in items
@@ -823,7 +830,17 @@ function _status_to_code(status::PlantSimEngine.Status, required_variables)
823830
if name in required_variables
824831
]
825832
isempty(kept) && return nothing
826-
return "Status(" * join(("$(first(item)) = $(repr(last(item)))" for item in kept), ", ") * ")"
833+
names_code = _tuple_code(_symbol_code.(first.(kept)))
834+
values_code = _tuple_code([repr(last(item)) for item in kept])
835+
return "Status(NamedTuple{$names_code}($values_code))"
836+
end
837+
838+
_symbol_code(symbol::Symbol) = repr(symbol)
839+
840+
function _tuple_code(items)
841+
values = collect(items)
842+
suffix = length(values) == 1 ? "," : ""
843+
return "(" * join(values, ", ") * suffix * ")"
827844
end
828845

829846
function _model_spec_to_code(spec::PlantSimEngine.ModelSpec)
@@ -866,27 +883,27 @@ end
866883
_mapped_variable_symbol(variable::Symbol) = variable
867884
_mapped_variable_symbol(variable::PlantSimEngine.PreviousTimeStep) = variable.variable
868885

869-
_mapped_lhs_to_code(variable::Symbol) = string(":", variable)
870-
_mapped_lhs_to_code(variable::PlantSimEngine.PreviousTimeStep) = "PreviousTimeStep(:$(variable.variable))"
886+
_mapped_lhs_to_code(variable::Symbol) = _symbol_code(variable)
887+
_mapped_lhs_to_code(variable::PlantSimEngine.PreviousTimeStep) = "PreviousTimeStep($(_symbol_code(variable.variable)))"
871888

872889
function _mapped_rhs_to_code(rhs::Pair{Symbol,Symbol}, variable::Symbol)
873890
source_scale = first(rhs)
874891
source_variable = last(rhs)
875892
if source_scale == Symbol("")
876-
return "(Symbol(\"\") => :$(source_variable))"
893+
return "($(_symbol_code(source_scale)) => $(_symbol_code(source_variable)))"
877894
end
878895
if source_variable == variable
879-
return ":$(source_scale)"
896+
return _symbol_code(source_scale)
880897
end
881-
return "(:$(source_scale) => :$(source_variable))"
898+
return "($(_symbol_code(source_scale)) => $(_symbol_code(source_variable)))"
882899
end
883900

884901
function _mapped_rhs_to_code(rhs::AbstractVector{<:Pair{Symbol,Symbol}}, variable::Symbol)
885902
compact = all(last(i) == variable for i in rhs)
886903
if compact
887-
return "[" * join((":" * string(first(i)) for i in rhs), ", ") * "]"
904+
return "[" * join((_symbol_code(first(i)) for i in rhs), ", ") * "]"
888905
end
889-
return "[" * join(("(:$(first(i)) => :$(last(i)))" for i in rhs), ", ") * "]"
906+
return "[" * join(("($(_symbol_code(first(i))) => $(_symbol_code(last(i))))" for i in rhs), ", ") * "]"
890907
end
891908

892909
end

frontend/dist/.vite/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"index.html": {
3-
"file": "assets/index--wMugJW5.js",
3+
"file": "assets/index--4z54nN9.js",
44
"name": "index",
55
"src": "index.html",
66
"isEntry": true,
77
"css": [
8-
"assets/index-Wf_4MG8d.css"
8+
"assets/index-DwZ0xeih.css"
99
]
1010
}
1111
}
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/dist/assets/index-DwZ0xeih.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/dist/assets/index-Wf_4MG8d.css

Lines changed: 0 additions & 1 deletion
This file was deleted.

frontend/dist/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>PlantSimEngine Dependency Graph</title>
7-
<script type="module" crossorigin src="/assets/index--wMugJW5.js"></script>
8-
<link rel="stylesheet" crossorigin href="/assets/index-Wf_4MG8d.css">
7+
<script type="module" crossorigin src="/assets/index--4z54nN9.js"></script>
8+
<link rel="stylesheet" crossorigin href="/assets/index-DwZ0xeih.css">
99
</head>
1010
<body>
1111
<div id="root"></div>

frontend/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ export default function App() {
555555
}, [flowInstance, focusEdge, focusNode, graph.edges, nodeById, nodes, portById]);
556556

557557
return (
558-
<main className={`app-shell ${viewMode === "overview" ? "overview-mode" : "detail-mode"} ${candidatePopover ? "has-candidate-popover" : ""} ${cycleBreakMode ? "cycle-break-mode" : ""}`}>
558+
<main className={`app-shell ${activePanel ? "has-side-panel" : ""} ${viewMode === "overview" ? "overview-mode" : "detail-mode"} ${candidatePopover ? "has-candidate-popover" : ""} ${cycleBreakMode ? "cycle-break-mode" : ""}`}>
559559
<section className="graph-panel">
560560
<div className="topbar graph-workbench">
561561
<button

frontend/src/styles.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,15 @@ body {
3232

3333
.app-shell {
3434
display: grid;
35-
grid-template-columns: minmax(0, 1fr) 340px;
35+
grid-template-columns: minmax(0, 1fr);
3636
height: 100vh;
3737
position: relative;
3838
}
3939

40+
.app-shell.has-side-panel {
41+
grid-template-columns: minmax(0, 1fr) 340px;
42+
}
43+
4044
.graph-panel {
4145
position: relative;
4246
min-width: 0;

0 commit comments

Comments
 (0)