diff --git a/docs/ui/optimize-page.md b/docs/ui/optimize-page.md index 46ffdc7..113d57b 100644 --- a/docs/ui/optimize-page.md +++ b/docs/ui/optimize-page.md @@ -47,7 +47,21 @@ For each mode you can: ![Configure tab in Greedy mode with one view and a dimension position rule in the Config Preview](../assets/images/optimuspy/ui/optimize-configure-tab.png) -The **Config Preview** beside the form shows the generated JSON config (it's identical to what the CLI consumes). **Save & Start Optimization** saves it to the cube configs folder, `cube-configs/` by default, starts the job in the background and opens the Optimize tab. **Save Config Only** saves it without starting, and the toast shows the file's full path. The [Folders card](settings-page.md#folders) on Settings changes the folder. +The **Config Preview** beside the form shows the generated JSON config (it's identical to what the CLI consumes). **Save & Start Optimization** saves it to the cube configs folder, `cube-configs/` by default, starts the job in the background and opens the Optimize tab. **Save Config Only** saves it without starting. The [Folders card](settings-page.md#folders) on Settings changes the folder. + +### Running a saved config from a TI process + +After **Save Config Only**, a pop-up shows the file's full path and the TI code that runs it, with a **Copy TI code** button: + +``` +sCommand = '"C:\OptimusPy\optimuspy.exe" optimize "C:\OptimusPy\cube-configs\Sales_tm1srv01.json"'; +# 1 waits for OptimusPy to finish; 0 starts it and carries on. +ExecuteCommand(sCommand, 1); +``` + +Paste it into the Prolog of a TI process on the same TM1 server. The code names the `optimuspy.exe` the UI is running from, so it assumes OptimusPy is installed on the TM1 server, and it reads the same `config.ini` as the UI. The password therefore has to be stored in `config.ini`: the one typed into the UI isn't saved. + +`ExecuteCommand` doesn't pass OptimusPy's exit code back to TI, so to see how a run went, read `logs/optimuspy.log` in the OptimusPy folder (the pop-up shows its full path). ![Config Preview with the Save & Start Optimization and Save Config Only buttons](../assets/images/optimuspy/ui/optimize-config-preview.png) diff --git a/src/optimuspy/static/app.js b/src/optimuspy/static/app.js index 82c03b6..3d86220 100644 --- a/src/optimuspy/static/app.js +++ b/src/optimuspy/static/app.js @@ -2532,7 +2532,7 @@ const OptimusPy = (function () { const filename = `${config.cube}_${config.instance}.json`; const resp = await Api.saveConfig(config, filename); Sidebar.loadSavedCubes(); - Toast.success(`Config saved to ${resp.path}`); + showRunFromTiModal(resp); } catch (err) { Toast.error(err.message); } @@ -2546,6 +2546,34 @@ const OptimusPy = (function () { layout.appendChild(rightCol); container.appendChild(layout); + // After Save Config Only: where the file went, and the TI code that runs it. + function showRunFromTiModal(resp) { + const copyBtn = el("button", { className: "btn btn-primary" }, "Copy TI code"); + copyBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(resp.ti_snippet); + Toast.success("TI code copied"); + } catch (err) { + Toast.error("Couldn't copy. Select the code and copy it by hand."); + } + }); + Modal.open({ + title: "Config saved", + body: el("div", null, + el("p", null, "Saved to ", el("code", null, resp.path), "."), + el("p", { className: "mt-4" }, "To run it from a TI process on this server, paste this into the Prolog:"), + el("pre", { className: "json-preview mt-2" }, resp.ti_snippet), + el("p", { className: "text-sm mt-2" }, + "ExecuteCommand doesn't pass OptimusPy's result back to TI. To see how the run went, read ", + el("code", null, resp.log_path), "."), + ), + footer: [ + el("button", { className: "btn btn-secondary", onClick: () => Modal.close() }, "Close"), + copyBtn, + ], + }); + } + const self = this; function buildConfig() { const dimConfig = self._dimConfigurator ? self._dimConfigurator.getConfig() : { included: [], excluded: [] }; diff --git a/src/optimuspy/ui.py b/src/optimuspy/ui.py index ce867db..ab212b1 100644 --- a/src/optimuspy/ui.py +++ b/src/optimuspy/ui.py @@ -112,6 +112,30 @@ def _folders_state() -> dict: _config_source = "default" +def run_command(config_path) -> str: + """The command line that runs a saved cube config, for a TI's ExecuteCommand. + + It names this install: the executable itself when running as the bundle, + else this Python with -m optimuspy. --config is added only when the UI was + started with it, because the settings link and config/config.ini are found + from the install folder the executable changes into. + """ + launcher = [f'"{sys.executable}"'] if getattr(sys, "frozen", False) else [f'"{sys.executable}"', "-m", "optimuspy"] + parts = launcher + ["optimize", f'"{os.path.abspath(config_path)}"'] + if _config_source == "flag": + parts += ["--config", f'"{os.path.abspath(_config_ini_path)}"'] + return " ".join(parts) + + +def ti_snippet(command: str) -> str: + """TI code that runs the command. A quote inside a TI string is doubled.""" + return ( + "sCommand = '" + command.replace("'", "''") + "';\n" + "# 1 waits for OptimusPy to finish; 0 starts it and carries on.\n" + "ExecuteCommand(sCommand, 1);" + ) + + def _resolve_static_dir() -> Path: """Resolve the static/ directory — works for pip install and PyInstaller frozen exe.""" return Path(__file__).parent / "static" @@ -893,7 +917,10 @@ def _handle_save_config(self, body: dict): except OSError as e: return self._send_json(500, {"error": _error_text(e)}) - self._send_json(200, {"path": os.path.abspath(config_path), "filename": safe_name}) + command = run_command(config_path) + self._send_json(200, {"path": os.path.abspath(config_path), "filename": safe_name, + "command": command, "ti_snippet": ti_snippet(command), + "log_path": str(get_logfile_path())}) def _handle_delete_config(self, filename: str): safe_name = "".join(c for c in filename if c.isalnum() or c in "._-") diff --git a/tests/test_ui_server.py b/tests/test_ui_server.py index ee419e4..e5ad1e5 100644 --- a/tests/test_ui_server.py +++ b/tests/test_ui_server.py @@ -5,6 +5,7 @@ """ import json import logging +import sys import threading import time import urllib.error @@ -650,6 +651,38 @@ def test_saved_cube_configs_go_to_cube_configs_by_default(ui_server, tmp_path): assert json.loads(saved.read_text(encoding="utf-8")) == CUBE_CONFIG +def test_saving_a_config_returns_the_ti_code_that_runs_it(ui_server, tmp_path, monkeypatch): + monkeypatch.setattr(sys, "frozen", True, raising=False) + exe = tmp_path / "optimuspy.exe" + monkeypatch.setattr(sys, "executable", str(exe)) + base, _ = ui_server(INI) + _, _, text = request("POST", f"{base}/api/config", body={"config": CUBE_CONFIG, "filename": "sales"}) + payload = json.loads(text) + saved = tmp_path / "cube-configs" / "sales.json" + assert payload["command"] == f'"{exe}" optimize "{saved}"' + command = payload["command"] + assert f"sCommand = '{command}';" in payload["ti_snippet"] + assert "ExecuteCommand(sCommand, 1);" in payload["ti_snippet"] + assert payload["log_path"] == str(tmp_path / "logs" / "optimuspy.log") + + +def test_the_run_command_names_the_config_ini_only_when_the_ui_was_started_with_it(ui_server, tmp_path): + base, ini = ui_server(INI, source="flag") + _, _, text = request("POST", f"{base}/api/config", body={"config": CUBE_CONFIG, "filename": "sales"}) + assert json.loads(text)["command"].endswith(f'--config "{ini}"') + assert ' -m optimuspy optimize ' in json.loads(text)["command"] + + base, _ = ui_server(INI, source="linked") + _, _, text = request("POST", f"{base}/api/config", body={"config": CUBE_CONFIG, "filename": "sales"}) + assert "--config" not in json.loads(text)["command"] + + +def test_a_quote_in_the_command_is_doubled_in_the_ti_code(): + command = '"/opt/optimuspy" optimize "/data/Bob' + "'" + 's/sales.json"' + assert ui.ti_snippet(command).startswith( + "sCommand = '\"/opt/optimuspy\" optimize \"/data/Bob''s/sales.json\"';") + + def test_a_chosen_folder_is_used_to_save_list_and_delete(ui_server, tmp_path): base, _ = ui_server(INI) folder = tmp_path / "shared" / "cubes"