From 80b8e7724233884e341a884efaaf935462ccf8da Mon Sep 17 00:00:00 2001 From: Lori Burns Date: Wed, 3 Dec 2025 03:42:38 -0500 Subject: [PATCH 1/6] handle legacy images.path --- iris/project.py | 1 - 1 file changed, 1 deletion(-) diff --git a/iris/project.py b/iris/project.py index fdd7e050..cb005678 100644 --- a/iris/project.py +++ b/iris/project.py @@ -67,7 +67,6 @@ def load_from(self, filename): self.config['images']['path'] = { 'pictures': self.config['images']['path'] } except Exception: # Non-fatal; leave config as-is on error - logger.warning(f"Failed to normalize images.path: {e}") pass self._init_paths_and_files(filename) From 52e0d9682e079ec67358f103a01309b168265427 Mon Sep 17 00:00:00 2001 From: Lori Burns Date: Tue, 9 Dec 2025 12:21:34 -0500 Subject: [PATCH 2/6] misc code review --- iris/project.py | 1 + 1 file changed, 1 insertion(+) diff --git a/iris/project.py b/iris/project.py index cb005678..fdd7e050 100644 --- a/iris/project.py +++ b/iris/project.py @@ -67,6 +67,7 @@ def load_from(self, filename): self.config['images']['path'] = { 'pictures': self.config['images']['path'] } except Exception: # Non-fatal; leave config as-is on error + logger.warning(f"Failed to normalize images.path: {e}") pass self._init_paths_and_files(filename) From 3a10fc01c8b1c63195a0bc71cafc4f0c3d65ddea Mon Sep 17 00:00:00 2001 From: Lori Burns Date: Tue, 9 Dec 2025 17:03:25 -0500 Subject: [PATCH 3/6] general panel tweaks and debug printing --- iris/api/routes/config.py | 5 ++ iris/cli.py | 4 +- iris/tests/test_api_config.py | 11 +++++ .../preferences/ProjectConfigTab.tsx | 9 ++++ .../preferences/config/GeneralSection.tsx | 46 +++++++++++-------- .../preferences/config/PathListEditor.tsx | 4 +- 6 files changed, 56 insertions(+), 23 deletions(-) diff --git a/iris/api/routes/config.py b/iris/api/routes/config.py index ff5e5103..972a3171 100644 --- a/iris/api/routes/config.py +++ b/iris/api/routes/config.py @@ -79,6 +79,11 @@ def get_project_config(): 'name': project.config.get('name'), 'host': project.config.get('host', '127.0.0.1'), 'port': project.config.get('port', 5000), + # Expose whether the server/project was started in debug mode. + # This is set by start_server(..., debug=True) and stored on the + # project object, so the frontend can decide to enable verbose + # debug behavior such as pretty-printing the saved config. + 'debug': getattr(project, 'debug', False), 'images': { 'path': deepcopy(project.config['images']['path']), 'shape': deepcopy(project.config['images']['shape']), diff --git a/iris/cli.py b/iris/cli.py index d6223d76..9026fd16 100644 --- a/iris/cli.py +++ b/iris/cli.py @@ -85,6 +85,8 @@ def label( @app.command() def launch( folder: Annotated[str, typer.Argument(help="Project folder name to create or launch")], + debug: Annotated[bool, typer.Option("--debug", "-d", help="Start in debug mode")] = False, + production: Annotated[bool, typer.Option("--production", "-p", help="Use production WSGI server")] = False, ): """ Create a new project from the demo template or launch an existing one. @@ -101,7 +103,7 @@ def launch( try: config_file = handle_launch_command(folder) typer.echo(f"Launching project '{folder}' with config '{config_file}'...") - start_server(project_file=str(config_file), debug=False, production=False) + start_server(project_file=str(config_file), debug=debug, production=production) except (ValueError, FileNotFoundError, RuntimeError) as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(code=1) diff --git a/iris/tests/test_api_config.py b/iris/tests/test_api_config.py index c40b0f03..bfe49fc5 100644 --- a/iris/tests/test_api_config.py +++ b/iris/tests/test_api_config.py @@ -400,6 +400,17 @@ def test_put_config_requires_admin(app, client): assert response.status_code == 403 +def test_api_project_includes_debug(logged_in_admin): + """API should return a 'debug' flag reflecting server start mode""" + response = logged_in_admin.get('/api/config/project') + assert response.status_code == 200 + data = response.json + assert 'config' in data + config = data['config'] + assert 'debug' in config, "Expected 'debug' key in config payload" + assert isinstance(config['debug'], bool) + + def test_load_from_normalizes_images_path(tmp_path, sample_valid_config): """Ensure Project.load_from() converts single-string images.path into a dict""" # Prepare a temporary project directory with an images subfolder and a dummy file diff --git a/src/components/preferences/ProjectConfigTab.tsx b/src/components/preferences/ProjectConfigTab.tsx index aaa9cbff..15cd8fe1 100644 --- a/src/components/preferences/ProjectConfigTab.tsx +++ b/src/components/preferences/ProjectConfigTab.tsx @@ -268,6 +268,15 @@ const ProjectConfigTab: React.FC = ({ onStateChange }) => return; } + // Only log the full configuration if the project was started in debug mode + if (loadedConfig && (loadedConfig as any).debug) { + try { + console.log('Save Complete Configuration - full config:', JSON.stringify(config, null, 2)); + } catch (e) { + console.log('Save Complete Configuration - full config (object):', config); + } + } + // Save to backend const response = await updateProjectConfig(config); diff --git a/src/components/preferences/config/GeneralSection.tsx b/src/components/preferences/config/GeneralSection.tsx index 857a71c1..12b24b04 100644 --- a/src/components/preferences/config/GeneralSection.tsx +++ b/src/components/preferences/config/GeneralSection.tsx @@ -105,7 +105,7 @@ const GeneralSection = forwardRef((_props, ref) => { Name
- Optional name for this project. (e.g., cloud-segmentation) + Optional name for this project. Example: cloud-segmentation @@ -147,7 +147,7 @@ const GeneralSection = forwardRef((_props, ref) => { Set the host IP address for IRIS. The default value 127.0.0.1 means IRIS will only be visible on the local machine. If you want to expose IRIS publicly as a web application, we recommend setting the host - to 0.0.0.0 and adjusting your router / consulting with your network administrators accordingly. + to 0.0.0.0 and adjusting your router or consulting with your network administrators accordingly. @@ -169,23 +169,29 @@ const GeneralSection = forwardRef((_props, ref) => { Path *
- - The input path(s) to the images. Paths should use the placeholder{' '} - {'{id}'}, which will be - replaced by the unique id of the current image (see example below). - Note: In the UI, the path must be provided as a dictionary. - Legacy single-string paths are automatically converted when loading from JSON files. - IRIS can load standard image formats (like *png* or - *tif*), theoretically all kind of files that can be opened by GDAL/rasterio (like *geotiff* or *vrt*) - and numpy files (*.npy*). The arrays inside the numpy files should have the shape HxWxC. - If you used a single file path for images, it has been assigned key 'pictures' here. + + Provide input image paths using the placeholder {'{id}'}. + Use a dictionary to map single or multiple dataset identifiers or file types to their path templates. +
    +
  • + Placeholder: Include {'{id}'} in + each path; it will be replaced by the current image id. +
  • +
  • + Dictionary keys: Use meaningful identifiers (e.g. Sentinel2) + as these are referenced to define Views (see Views panel). +
  • +
  • + Supported formats: common raster formats (png, tif, GeoTIFF, VRT) and + NumPy (*.npy*) arrays (HxWxC) are supported. +
  • +
  • + Legacy behaviour: If you provided a single string path in JSON, it will appear + as the "pictures" entry after loading. +
  • +
- - When you have your data distributed over multiple files (e.g. coming from Sentinel-1 and - Sentinel-2), you can use a dictionary for each file type. The keys of the dictionary are file - identifiers which are important for the [views](#views) configuration. -
((_props, ref) => {
                   >
                     {`"path": {
     "Sentinel1": "images/{id}/S1.tif",
-    "Sentinel2": "images/S2-{id}.tif"
+    "Sentinel2": "images/S2-{id}.png"
 }`}
                   
@@ -226,7 +232,7 @@ const GeneralSection = forwardRef((_props, ref) => {
((_props, ref) => {
((_props, ref) => { placeholder="optional key (e.g. Sentinel2)" value={path.key} onChange={(e) => updatePath(path.id, 'key', e.target.value)} - style={{ flex: '0 0 300px', padding: '6px' }} + style={{ flex: '0 0 30%', maxWidth: '30%', padding: '6px' }} /> ((_props, ref) => { style={{ flex: 1, padding: '6px' }} />
- + Full or relative path to set of image files. Must use "{'{id}'}" placeholder.
From 3bca066d697b8ee76a33690e7e453f03b1309e91 Mon Sep 17 00:00:00 2001 From: Lori Burns Date: Tue, 9 Dec 2025 23:49:10 -0500 Subject: [PATCH 4/6] fixup --- iris/project.py | 1 - src/components/preferences/config/PathListEditor.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/iris/project.py b/iris/project.py index fdd7e050..cb005678 100644 --- a/iris/project.py +++ b/iris/project.py @@ -67,7 +67,6 @@ def load_from(self, filename): self.config['images']['path'] = { 'pictures': self.config['images']['path'] } except Exception: # Non-fatal; leave config as-is on error - logger.warning(f"Failed to normalize images.path: {e}") pass self._init_paths_and_files(filename) diff --git a/src/components/preferences/config/PathListEditor.tsx b/src/components/preferences/config/PathListEditor.tsx index 971bf697..93933a8c 100644 --- a/src/components/preferences/config/PathListEditor.tsx +++ b/src/components/preferences/config/PathListEditor.tsx @@ -126,7 +126,7 @@ const PathListEditor = forwardRef((_props, ref) => { style={{ flex: 1, padding: '6px' }} /> - + Full or relative path to set of image files. Must use "{'{id}'}" placeholder. From eee06edea1f72e70549bbff3a9ea62927ee482e0 Mon Sep 17 00:00:00 2001 From: Lori Burns Date: Wed, 10 Dec 2025 02:25:47 -0500 Subject: [PATCH 5/6] fix missing {id} --- iris/api/routes/config.py | 4 ++ iris/project.py | 55 ++++++++++--------- iris/tests/test_api_config_validation.py | 20 +++++++ .../preferences/ProjectConfigTab.tsx | 19 ++++++- .../preferences/config/GeneralSection.tsx | 13 +++-- 5 files changed, 76 insertions(+), 35 deletions(-) diff --git a/iris/api/routes/config.py b/iris/api/routes/config.py index 972a3171..b2925534 100644 --- a/iris/api/routes/config.py +++ b/iris/api/routes/config.py @@ -325,6 +325,10 @@ def validate_project_config(): elif isinstance(config_data['images']['path'], str): if '{id}' not in config_data['images']['path'] and config_data['images']['path'] != '': warnings.append('images.path should contain {id} placeholder') + elif isinstance(config_data['images']['path'], dict): + for k, v in config_data['images']['path'].items(): + if '{id}' not in v and v != '': + warnings.append(f'images.path {k} should contain {{id}} placeholder') if 'shape' not in config_data['images']: errors.append('images.shape is required') diff --git a/iris/project.py b/iris/project.py index cb005678..58918a66 100644 --- a/iris/project.py +++ b/iris/project.py @@ -171,35 +171,36 @@ def _init_paths_and_files(self, filename): ) if isinstance(self['images']['path'], dict): - image_paths = list(self['images']['path'].values())[0] + image_paths = list(self['images']['path'].values()) else: - image_paths = self['images']['path'] - - # We will need to extract the image id by using regex. Compile it here - # to get a better perfomance: - before, id_str, after = image_paths.partition("{id}") - if not id_str: - raise Exception('[CONFIG] images:path must contain exactly one placeholder "{id}"!') - escaped_path = re.escape(before) - escaped_path += "(?P.+)" - escaped_path += re.escape(after) - regex_images = re.compile(escaped_path) - - images = glob(image_paths.format(id="*")) - if not images: - raise Exception( - f"[CONFIG] No images found in '{image_paths.format(id='*')}'.\n" - "Did you set images:path to a valid, existing path?") + image_paths = [self['images']['path']] + + for ip_idx, image_path in enumerate(image_paths): + # We will need to extract the image id by using regex. Compile it here + # to get a better perfomance: + before, id_str, after = image_path.partition("{id}") + if not id_str: + raise Exception(f'[CONFIG] images:path {ip_idx+1} must contain exactly one placeholder "{{id}}"!') + escaped_path = re.escape(before) + escaped_path += "(?P.+)" + escaped_path += re.escape(after) + regex_images = re.compile(escaped_path) + + images = glob(image_path.format(id="*")) + if not images: + raise Exception( + f"[CONFIG] No images found in '{image_path.format(id='*')}'.\n" + "Did you set images:path to a valid, existing path?") - try: - self.image_ids = list(sorted([ - regex_images.match(image_path).groups()[0] - for image_path in images - ])) - except Exception as error: - raise Exception( - f'[ERROR] Could not extract id\nfrom path"{image_paths}"\nwith regex "{regex_images}"!' - ) + try: + self.image_ids = list(sorted([ + regex_images.match(image_path).groups()[0] + for image_path in images + ])) + except Exception as error: + raise Exception( + f'[ERROR] Could not extract id\nfrom path"{image_path}"\nwith regex "{regex_images}"!' + ) def make_absolute(self, path): diff --git a/iris/tests/test_api_config_validation.py b/iris/tests/test_api_config_validation.py index 65753dd0..ecc29832 100644 --- a/iris/tests/test_api_config_validation.py +++ b/iris/tests/test_api_config_validation.py @@ -132,6 +132,26 @@ def test_validate_warns_missing_id_placeholder(logged_in_admin, sample_valid_con assert any('{id}' in warn.lower() or 'placeholder' in warn.lower() for warn in data['warnings']) +def test_validate_warns_missing_id_placeholder_dict(logged_in_admin, sample_valid_config): + """Test that validation warns when images.path lacks {id} placeholder""" + sample_valid_config['images']['path'] = { + 'Sent1': 'test/{id}/image.tif', + 'Sent2': 'test/image.tif', # No {id} + } + + response = logged_in_admin.post('/api/config/project/validate', + json=sample_valid_config, + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.json + # Should be valid but with warning + print(data['warnings']) + assert any('{id}' in warn.lower() or 'placeholder' in warn.lower() for warn in data['warnings']) + assert any('sent2' in warn.lower() for warn in data['warnings']) + + def test_validate_detects_colour_value_out_of_range(logged_in_admin, sample_valid_config): """Test that validation detects colour values outside 0-255 range""" sample_valid_config['classes'][0]['colour'] = [256, 0, 0, 255] # 256 is too high diff --git a/src/components/preferences/ProjectConfigTab.tsx b/src/components/preferences/ProjectConfigTab.tsx index 15cd8fe1..be7ff153 100644 --- a/src/components/preferences/ProjectConfigTab.tsx +++ b/src/components/preferences/ProjectConfigTab.tsx @@ -264,7 +264,15 @@ const ProjectConfigTab: React.FC = ({ onStateChange }) => const validationResult = await validateProjectConfig(config); if (!validationResult.valid) { - setError(`Validation failed: ${validationResult.errors.join(', ')}`); + const msg = `Validation failed: ${validationResult.errors.join('\n')}`; + setError(msg); + // Make sure the user sees the validation errors immediately + try { + // eslint-disable-next-line no-alert + window.alert(msg); + } catch (e) { + // ignore if alerts are not available + } return; } @@ -290,7 +298,14 @@ const ProjectConfigTab: React.FC = ({ onStateChange }) => } catch (err: any) { console.error('[ProjectConfigTab] Failed to save configuration:', err); - setError(err.message || 'Failed to save configuration'); + const msg = err?.message || 'Failed to save configuration'; + setError(msg); + try { + // eslint-disable-next-line no-alert + window.alert(msg); + } catch (e) { + // ignore if alerts are not available + } } finally { setSaving(false); } diff --git a/src/components/preferences/config/GeneralSection.tsx b/src/components/preferences/config/GeneralSection.tsx index 12b24b04..0dcfbcdb 100644 --- a/src/components/preferences/config/GeneralSection.tsx +++ b/src/components/preferences/config/GeneralSection.tsx @@ -261,10 +261,10 @@ const GeneralSection = forwardRef((_props, ref) => {

Thumbnails

- + Optional thumbnail files for the images. Path must contain a placeholder{' '} - {'{id}'}. If you cannot provide any thumbnail, just leave - it out or set it to False. Example:{' '} + {'{id}'}. If you cannot provide any thumbnail, un-check to disable, + or set it to False in JSON. Example:{' '} thumbnails/{'{id}'}.png
@@ -297,13 +297,13 @@ const GeneralSection = forwardRef((_props, ref) => {

Metadata

- + Optional metadata for the images. Path must contain a placeholder{' '} {'{id}'}. Metadata files can be in json, yaml or another text file format. json and yaml files will be parsed and made available via the GUI. If the metadata contains the key location with a list of two floats - (longitude and latitude), it can be used for a bingmap view. If you cannot provide any metadata, just - leave it out or set it to false. Example field:{' '} + (longitude and latitude), it can be used for a bingmap view. If you cannot provide any metadata, un-check + to disable or set it to false in JSON. Example field:{' '} metadata/{'{id}'}.json. Example file contents:
((_props, ref) => {
                     borderRadius: '4px',
                     fontSize: '12px',
                     marginBottom: '12px',
+                    textAlign: 'left',
                   }}
                 >
                   {`{

From b95e4a8fbf9b1a7f59007cf003eb192b69cf6099 Mon Sep 17 00:00:00 2001
From: Lori Burns 
Date: Wed, 10 Dec 2025 03:12:08 -0500
Subject: [PATCH 6/6] fixes

---
 iris/project.py                               |  7 +++--
 iris/tests/test_api_config_validation.py      |  1 -
 .../preferences/ProjectConfigTab.tsx          | 26 +++++++++----------
 .../preferences/config/GeneralSection.tsx     |  4 +--
 4 files changed, 20 insertions(+), 18 deletions(-)

diff --git a/iris/project.py b/iris/project.py
index 58918a66..015c1359 100644
--- a/iris/project.py
+++ b/iris/project.py
@@ -177,7 +177,7 @@ def _init_paths_and_files(self, filename):
 
         for ip_idx, image_path in enumerate(image_paths):
             # We will need to extract the image id by using regex. Compile it here
-            # to get a better perfomance:
+            # to get a better performance:
             before, id_str, after = image_path.partition("{id}")
             if not id_str:
                 raise Exception(f'[CONFIG] images:path {ip_idx+1} must contain exactly one placeholder "{{id}}"!')
@@ -193,7 +193,7 @@ def _init_paths_and_files(self, filename):
                     "Did you set images:path to a valid, existing path?")
 
             try:
-                self.image_ids = list(sorted([
+                image_ids = list(sorted([
                     regex_images.match(image_path).groups()[0]
                     for image_path in images
                 ]))
@@ -201,6 +201,9 @@ def _init_paths_and_files(self, filename):
                 raise Exception(
                     f'[ERROR] Could not extract id\nfrom path"{image_path}"\nwith regex "{regex_images}"!'
                 )
+            else:
+                if ip_idx == 0:
+                    self.image_ids = image_ids
 
 
     def make_absolute(self, path):
diff --git a/iris/tests/test_api_config_validation.py b/iris/tests/test_api_config_validation.py
index ecc29832..2196dcfa 100644
--- a/iris/tests/test_api_config_validation.py
+++ b/iris/tests/test_api_config_validation.py
@@ -147,7 +147,6 @@ def test_validate_warns_missing_id_placeholder_dict(logged_in_admin, sample_vali
     assert response.status_code == 200
     data = response.json
     # Should be valid but with warning
-    print(data['warnings'])
     assert any('{id}' in warn.lower() or 'placeholder' in warn.lower() for warn in data['warnings'])
     assert any('sent2' in warn.lower() for warn in data['warnings'])
 
diff --git a/src/components/preferences/ProjectConfigTab.tsx b/src/components/preferences/ProjectConfigTab.tsx
index be7ff153..632f0f55 100644
--- a/src/components/preferences/ProjectConfigTab.tsx
+++ b/src/components/preferences/ProjectConfigTab.tsx
@@ -266,13 +266,6 @@ const ProjectConfigTab: React.FC = ({ onStateChange }) =>
       if (!validationResult.valid) {
         const msg = `Validation failed: ${validationResult.errors.join('\n')}`;
         setError(msg);
-        // Make sure the user sees the validation errors immediately
-        try {
-          // eslint-disable-next-line no-alert
-          window.alert(msg);
-        } catch (e) {
-          // ignore if alerts are not available
-        }
         return;
       }
       
@@ -300,12 +293,6 @@ const ProjectConfigTab: React.FC = ({ onStateChange }) =>
       console.error('[ProjectConfigTab] Failed to save configuration:', err);
       const msg = err?.message || 'Failed to save configuration';
       setError(msg);
-      try {
-        // eslint-disable-next-line no-alert
-        window.alert(msg);
-      } catch (e) {
-        // ignore if alerts are not available
-      }
     } finally {
       setSaving(false);
     }
@@ -360,6 +347,19 @@ const ProjectConfigTab: React.FC = ({ onStateChange }) =>
       
       
       
+ {/* Duplicate error banner near the Save button for better visibility */} + {error && ( +
+ Error: {error} +
+ )}