Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/ui/optimize-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
30 changes: 29 additions & 1 deletion src/optimuspy/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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: [] };
Expand Down
29 changes: 28 additions & 1 deletion src/optimuspy/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 "._-")
Expand Down
33 changes: 33 additions & 0 deletions tests/test_ui_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""
import json
import logging
import sys
import threading
import time
import urllib.error
Expand Down Expand Up @@ -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"
Expand Down
Loading