diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..43095de3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - run: npm ci --legacy-peer-deps
+
+ - run: npm run build
diff --git a/README.md b/README.md
index e7229525..454768a9 100644
--- a/README.md
+++ b/README.md
@@ -8,15 +8,46 @@ A free, public web tool for browsing [Time-Addressable Media Store (TAMS)](https
- **Multi-store management** -- Connect to multiple TAMS endpoints, switch between them instantly
- **Sources & Flows browser** -- Filterable, sortable tables with customisable columns
+- **Scoped listings** -- Show only top-level sources or flows, or only the multi-entities that collect others
+- **Ingest status** -- See which flows are growing, from the TAMS 8.2 `status` attribute
+- **Flow Profiles** -- Browse profiles and the technical metadata they apply
+- **Webhooks** -- List, register, update, and delete webhooks; inspect delivery errors
- **Omakase Player** -- Advanced video player with timeline visualisation
- **Diagram View** -- Interactive graph of TAMS entity relationships
- **Zero backend** -- Everything runs in your browser; credentials stay in localStorage
+## TAMS version support
+
+Targets **TAMS 8.2**. TAMS 8.0 and 8.1 stores work, with the 8.2-only features
+below hidden or degraded -- Flow Profiles are unavailable, scoped listings and
+the hierarchy are built in the browser from a full listing, storage backend tag
+filtering is hidden, and ingest status comes from the deprecated `flow_status`
+tag.
+
+The tool reads `api_version` from the store's `/service` endpoint and gates the
+8.2 query parameters listed below on it, so pointing it at an older store
+degrades cleanly rather than erroring. The detected version is shown beside
+**Manage Stores** in the sidebar and in the store list.
+
+8.2 features used where available:
+
+| Feature | Used for |
+|---|---|
+| `sort_by` / `reverse_order` | Latest sources and flows in one request, sorted by the store |
+| `collected_by_ids` | Top-level and multi-source scopes, collection contents, hierarchy |
+| Flow `status` | Ingest/growing state, status column and filter (falls back to the deprecated `flow_status` tag) |
+| `editorial_purpose` tag | Identifying collection members now that `role` is optional |
+| `/service/profiles`, `profile_id` | Flow Profiles browser and the Profile shown on a Flow |
+| `init_segments` | Flow column, from `essence_parameters` (pre-8.2 stores use the `init_segment` tag) |
+| `storage_backend_tag.{name}` | Narrowing segment `get_urls` by storage backend |
+| `include_object_timerange` | Webhook registration option (also sent on segment listings, which pre-8.2 stores ignore) |
+
## Quick Start
1. Visit [cuttingroom.github.io/tamstool](https://cuttingroom.github.io/tamstool)
2. Click **Manage Stores**
-3. Add your TAMS endpoint URL (and optional Bearer token)
+3. Add your TAMS endpoint URL and credentials (OAuth2 client credentials by
+ default; a Bearer token or no auth are also available)
4. Browse Sources and Flows
## Local Development
diff --git a/package-lock.json b/package-lock.json
index b32fe43b..cdfbb7f6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,13 +49,13 @@
}
},
"node_modules/@babel/code-frame": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
- "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -64,9 +64,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
- "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -74,21 +74,21 @@
}
},
"node_modules/@babel/core": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
- "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-compilation-targets": "^7.28.6",
- "@babel/helper-module-transforms": "^7.28.6",
- "@babel/helpers": "^7.28.6",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/traverse": "^7.29.0",
- "@babel/types": "^7.29.0",
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -105,14 +105,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.29.1",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
- "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.29.0",
- "@babel/types": "^7.29.0",
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -122,14 +122,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
- "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.28.6",
- "@babel/helper-validator-option": "^7.27.1",
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -139,9 +139,9 @@
}
},
"node_modules/@babel/helper-globals": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
- "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -149,29 +149,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
- "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
- "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.28.6",
- "@babel/helper-validator-identifier": "^7.28.5",
- "@babel/traverse": "^7.28.6"
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -191,9 +191,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -201,9 +201,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -211,9 +211,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
- "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -221,27 +221,27 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
- "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
- "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.0"
+ "@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -292,33 +292,33 @@
}
},
"node_modules/@babel/template": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
- "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/parser": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
- "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.29.0",
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
"debug": "^4.3.1"
},
"engines": {
@@ -326,14 +326,14 @@
}
},
"node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1764,16 +1764,6 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
- "node_modules/@types/react": {
- "version": "19.2.15",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
- "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "csstype": "^3.2.2"
- }
- },
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
@@ -1989,16 +1979,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
- "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
@@ -2383,13 +2373,13 @@
}
},
"node_modules/axios": {
- "version": "1.16.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
- "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
+ "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
- "form-data": "^4.0.5",
+ "form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
@@ -2412,9 +2402,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
- "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "version": "2.11.15",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
+ "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2425,9 +2415,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2448,9 +2438,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.1",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
- "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -2468,11 +2458,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.9.0",
- "caniuse-lite": "^1.0.30001759",
- "electron-to-chromium": "^1.5.263",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.2.0"
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -2539,9 +2529,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001774",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
- "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
+ "version": "1.0.30001809",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
+ "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
"dev": true,
"funding": [
{
@@ -3028,9 +3018,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.302",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
- "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
+ "version": "1.5.409",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.409.tgz",
+ "integrity": "sha512-ChI4N44d0B4A6C8prnNjMOaGgE59fUyEVYcRYm2XEXIjMbbvF5i9UL1cblDbpGqiU0uS8FE8UcKxqZqTXdmzbQ==",
"dev": true,
"license": "ISC"
},
@@ -3665,16 +3655,16 @@
}
},
"node_modules/form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -3973,9 +3963,9 @@
}
},
"node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -4651,10 +4641,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -5889,9 +5889,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -5934,11 +5934,14 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.27",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
- "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "version": "2.0.53",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
+ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/node-webvtt": {
"version": "1.9.4",
@@ -6281,9 +6284,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -6301,7 +6304,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.12",
+ "nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -6456,9 +6459,9 @@
}
},
"node_modules/react-router": {
- "version": "7.13.1",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
- "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
+ "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -6478,12 +6481,12 @@
}
},
"node_modules/react-router-dom": {
- "version": "7.13.1",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
- "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
+ "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT",
"dependencies": {
- "react-router": "7.13.1"
+ "react-router": "7.18.2"
},
"engines": {
"node": ">=20.0.0"
@@ -7180,9 +7183,9 @@
}
},
"node_modules/tmp": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
- "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+ "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"license": "MIT",
"engines": {
"node": ">=14.14"
@@ -7484,9 +7487,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
+ "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
"dev": true,
"funding": [
{
@@ -7562,9 +7565,9 @@
}
},
"node_modules/vite": {
- "version": "6.4.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
- "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/src/App.jsx b/src/App.jsx
index 86991285..79937292 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -6,6 +6,8 @@ import Flow from "@/views/Flow";
import Flows from "@/views/Flows";
import OmakaseTamsPlayer from "@/views/OmakaseTamsPlayer";
import Home from "@/views/Home";
+import Profile from "@/views/Profile";
+import Profiles from "@/views/Profiles";
import Layout from "@/views/Layout";
import React from "react";
import Source from "@/views/Source";
@@ -30,6 +32,10 @@ const App = () => {
} />
} />
+
+ } />
+ } />
+
} />
} />
diff --git a/src/components/Collection.jsx b/src/components/Collection.jsx
index a2a7a48e..ca6100ed 100644
--- a/src/components/Collection.jsx
+++ b/src/components/Collection.jsx
@@ -1,42 +1,115 @@
+import { useMemo } from "react";
import { Link } from "react-router-dom";
-import { Table } from "@cloudscape-design/components";
-import { useCollection } from "@cloudscape-design/collection-hooks";
+import { Box, Table } from "@cloudscape-design/components";
+import { useCollectionMembers } from "@/hooks/useSources";
+import { getEditorialPurpose } from "@/utils/editorialPurpose";
+
+/**
+ * A Source or Flow collection.
+ *
+ * TAMS 8.2 made the collection `role` optional in favour of the collected
+ * entity's `editorial_purpose` tag, and requires the order of the collection to
+ * be preserved — so the rows are rendered in the order the store returned them
+ * rather than sorted, and `role` is only shown where a store still sets it.
+ *
+ * Where the store supports `collected_by_ids` the collected entities are
+ * fetched in a single request, which is what makes the label, format and
+ * editorial purpose columns possible at all.
+ */
+const Collection = ({ entityType, collection, parentId }) => {
+ // Only worth a request when there is actually a collection to describe.
+ const { members } = useCollectionMembers(
+ entityType,
+ collection?.length ? parentId : null
+ );
+
+ const membersById = useMemo(
+ () => new Map((members ?? []).map((member) => [member.id, member])),
+ [members]
+ );
+
+ const items = useMemo(
+ () =>
+ (collection ?? []).map((item) => {
+ const entity = membersById.get(item.id);
+ return {
+ ...item,
+ label: entity?.label,
+ format: entity?.format,
+ editorial_purpose: getEditorialPurpose(entity),
+ };
+ }),
+ [collection, membersById]
+ );
+
+ const hasRole = items.some((item) => item.role !== undefined);
+ // Flow collection items carry a container_mapping describing where this Flow's
+ // essence sits in the collection's container.
+ const hasContainerMapping =
+ entityType === "flows" &&
+ items.some((item) => item.container_mapping !== undefined);
-const Collection = ({ entityType, collection }) => {
const columnDefinitions = [
{
id: "id",
header: "Id",
cell: (item) => {item.id},
isRowHeader: true,
- sortingField: "id",
},
{
- id: "role",
- header: "Role",
- cell: (item) => item.role,
- sortingField: "role",
+ id: "label",
+ header: "Label",
+ cell: (item) => item.label,
},
- ]
-
- const { items, collectionProps } = useCollection(
- collection ?? [],
{
- sorting: {},
- }
- );
+ id: "editorial_purpose",
+ header: "Editorial purpose",
+ cell: (item) => item.editorial_purpose,
+ },
+ {
+ id: "format",
+ header: "Format",
+ cell: (item) => item.format,
+ },
+ ...(hasRole
+ ? [
+ {
+ id: "role",
+ header: "Role",
+ cell: (item) => item.role,
+ },
+ ]
+ : []),
+ ...(hasContainerMapping
+ ? [
+ {
+ id: "container_mapping",
+ header: "Container mapping",
+ cell: (item) =>
+ item.container_mapping
+ ? JSON.stringify(item.container_mapping)
+ : "",
+ },
+ ]
+ : []),
+ ];
+
+ if (!collection) return `No ${entityType} collection(s)`;
- return collection ? (
+ return (
+ Empty collection
+
+ }
/>
- ) : (
- `No ${entityType} collection(s)`
);
};
diff --git a/src/components/EntityDetails.jsx b/src/components/EntityDetails.jsx
index 0ed9c435..3037eb77 100644
--- a/src/components/EntityDetails.jsx
+++ b/src/components/EntityDetails.jsx
@@ -8,7 +8,11 @@ import {
import { Link } from "react-router-dom";
import ValueWithLabel from "@/components/ValueWithLabel";
import EditableField from "@/components/EditableField";
-import { DATE_FORMAT, STATUS_MAPPINGS } from "@/constants";
+import {
+ DATE_FORMAT,
+ FLOW_STATUS_MAPPINGS,
+ WEBHOOK_STATUS_MAPPINGS,
+} from "@/constants";
import chunkArray from "@/utils/chunkArray";
import { parseTimerangeDateTime } from "@/utils/timerange";
@@ -18,6 +22,8 @@ const excludedFields = [
"collected_by",
"essence_parameters",
"tags",
+ // Rendered by the Profile view's Flow metadata tab.
+ "flow_metadata",
];
const editableFields = ["label", "description"];
@@ -74,10 +80,18 @@ const EntityDetails = ({ entityType, entity }) => {
return {value};
}
- if (label === "status" && typeof value === "string" && STATUS_MAPPINGS[value]) {
- return (
- {value}
- );
+ if (label === "profile_id") {
+ return {value};
+ }
+
+ if (label === "status" && typeof value === "string") {
+ const mappings =
+ entityType === "flows" ? FLOW_STATUS_MAPPINGS : WEBHOOK_STATUS_MAPPINGS;
+ if (mappings[value]) {
+ return (
+ {value}
+ );
+ }
}
if (typeof value === "boolean") {
diff --git a/src/components/WebhookRegisterUpdateModal.jsx b/src/components/WebhookRegisterUpdateModal.jsx
index a487f557..1714f79e 100644
--- a/src/components/WebhookRegisterUpdateModal.jsx
+++ b/src/components/WebhookRegisterUpdateModal.jsx
@@ -28,6 +28,7 @@ const initialWebhookData = {
source_collected_by_ids: undefined,
accept_get_urls: undefined,
accept_storage_ids: undefined,
+ include_object_timerange: undefined,
presigned: undefined,
verbose_storage: undefined,
tags: undefined,
@@ -352,6 +353,20 @@ const WebhookRegisterUpdateModal = ({
}))
}
/>
+
+ setFormData((prev) => ({
+ ...prev,
+ include_object_timerange:
+ typeof v === "function"
+ ? v(prev.include_object_timerange)
+ : v,
+ }))
+ }
+ />
+ sortBy === "label" ? Boolean(isDescending) : !isDescending;
+
+const COLLECTION_FIELD = {
+ sources: "source_collection",
+ flows: "flow_collection",
+};
+
+/**
+ * Shared listing hook for /sources and /flows.
+ *
+ * On an 8.2 store the store does the work: `sort_by`/`reverse_order` put the
+ * most relevant records first so only the first page is fetched, and
+ * `collected_by_ids=` narrows the listing to top-level entities. Older stores
+ * keep the previous behaviour of walking every page and sorting in the browser,
+ * because their first page arrives in no guaranteed order.
+ *
+ * Hierarchical mode is the exception: the tree is fetched by structure, so the
+ * query filters and sort are not sent. Callers must apply them to the loaded
+ * rows instead, which is what `treeMode` in the return value is for.
+ */
+export const useEntityListing = (entityType, options = {}) => {
+ const {
+ viewMode = VIEW_MODE.ALL,
+ hierarchical = false,
+ sortBy,
+ reverseOrder = false,
+ status,
+ maxResults = RESULT_PAGE_SIZE,
+ refreshInterval,
+ } = options;
+
+ const api = useApi();
+ const capabilities = useCapabilities();
+
+ const scoped = capabilities.collectedByIds && viewMode !== VIEW_MODE.ALL;
+ const treeMode = capabilities.collectedByIds && hierarchical;
+
+ const path = buildQuery(`/${entityType}`, {
+ limit: TAMS_PAGE_LIMIT,
+ sort_by: capabilities.sortBy ? sortBy : undefined,
+ reverse_order: capabilities.sortBy && reverseOrder ? true : undefined,
+ collected_by_ids: scoped ? "" : undefined,
+ status: capabilities.flowStatus ? status : undefined,
+ });
+
+ const limit = capabilities.sortBy ? maxResults : null;
+
+ const { data, mutate, error, isLoading, isValidating } = useSWR(
+ api.endpoint && capabilities.resolved
+ ? [api.endpoint, entityType, treeMode ? "tree" : path, limit]
+ : null,
+ () =>
+ treeMode
+ ? fetchEntityTree(entityType, api, limit ?? RESULT_PAGE_SIZE)
+ : paginationFetcher(path, limit, api),
+ { refreshInterval, keepPreviousData: true }
+ );
+
+ // Applied client-side as well as through the query so that the scopes still
+ // work against stores that do not implement collected_by_ids. Memoised because
+ // the filtered array is a dependency of effects in the consuming views.
+ const items = useMemo(() => {
+ if (!data) return undefined;
+ if (viewMode === VIEW_MODE.ALL) return data.items;
+ const topLevel = data.items.filter((item) => !item.collected_by?.length);
+ if (viewMode === VIEW_MODE.TOP_LEVEL) return topLevel;
+ return topLevel.filter(
+ (item) => (item[COLLECTION_FIELD[entityType]]?.length ?? 0) > 0
+ );
+ }, [data, viewMode, entityType]);
+
+ return {
+ items,
+ hasMore: data?.hasMore ?? false,
+ truncated: data?.truncated ?? false,
+ loadedCount: data?.items.length ?? 0,
+ capabilities,
+ treeMode,
+ mutate,
+ isLoading: isLoading || !capabilities.resolved,
+ isValidating,
+ error,
+ };
+};
+
+export default useEntityListing;
diff --git a/src/hooks/useFlows.jsx b/src/hooks/useFlows.jsx
index fe694f72..fdb5291b 100644
--- a/src/hooks/useFlows.jsx
+++ b/src/hooks/useFlows.jsx
@@ -1,20 +1,36 @@
import { useApi } from "@/hooks/useApi";
import useSWR from "swr";
import useSWRMutation from "swr/mutation";
+import useEntityListing from "@/hooks/useEntityListing";
+import { useCapabilities } from "@/hooks/useService";
+import buildQuery from "@/utils/buildQuery";
import paginationFetcher from "@/utils/paginationFetcher";
+import { FLOW_STATUS } from "@/utils/flowStatus";
+import { TAMS_PAGE_LIMIT, TAMS_POLLING_INTERVAL } from "@/constants";
-const useFlowsQuery = (url) => {
+export const useFlows = (options = {}) => {
+ const { items, ...rest } = useEntityListing("flows", {
+ refreshInterval: TAMS_POLLING_INTERVAL,
+ ...options,
+ });
+
+ return { flows: items, ...rest };
+};
+
+export const useFlowsBySource = (sourceId) => {
const api = useApi();
+ const path = sourceId ? `/flows?source_id=${sourceId}` : null;
+
const { data, mutate, error, isLoading, isValidating } = useSWR(
- api.endpoint && url ? [api.endpoint, url] : null,
- ([, path]) => paginationFetcher(path, null, api),
+ api.endpoint && path ? [api.endpoint, path] : null,
+ ([, requestPath]) => paginationFetcher(requestPath, null, api),
{
- refreshInterval: 3000,
+ refreshInterval: TAMS_POLLING_INTERVAL,
}
);
return {
- flows: data,
+ flows: data?.items,
mutate,
isLoading,
isValidating,
@@ -22,10 +38,36 @@ const useFlowsQuery = (url) => {
};
};
-export const useFlows = () => useFlowsQuery("/flows?limit=300");
+/**
+ * Flows currently receiving content, via the 8.2 `status` filter.
+ *
+ * Returns `supported: false` on older stores, where the caller has to fall back
+ * to inspecting the deprecated flow_status tag on each Flow it already holds.
+ */
+export const useIngestingFlows = (enabled = true) => {
+ const api = useApi();
+ const { flowStatus: supported, resolved } = useCapabilities();
+
+ const path = buildQuery("/flows", {
+ limit: TAMS_PAGE_LIMIT,
+ status: FLOW_STATUS.INGESTING,
+ });
-export const useFlowsBySource = (sourceId) =>
- useFlowsQuery(sourceId ? `/flows?source_id=${sourceId}` : null);
+ const { data, error, isLoading } = useSWR(
+ api.endpoint && resolved && supported && enabled
+ ? [api.endpoint, "ingesting", path]
+ : null,
+ () => paginationFetcher(path, TAMS_PAGE_LIMIT, api),
+ { refreshInterval: TAMS_POLLING_INTERVAL }
+ );
+
+ return {
+ flows: data?.items,
+ supported: supported && resolved,
+ isLoading,
+ error,
+ };
+};
export const useFlow = (flowId) => {
const api = useApi();
@@ -37,9 +79,9 @@ export const useFlow = (flowId) => {
isValidating,
} = useSWR(
api.endpoint ? [api.endpoint, "/flows", flowId] : null,
- ([, path, flowId]) => api.get(`${path}/${flowId}?include_timerange=true`),
+ ([, path, id]) => api.get(`${path}/${id}?include_timerange=true`),
{
- refreshInterval: 3000,
+ refreshInterval: TAMS_POLLING_INTERVAL,
}
);
@@ -83,19 +125,3 @@ export const useDeleteTimerange = () => {
isDeletingTimerange: isMutating,
};
};
-
-export const useFlowStatusTag = () => {
- const api = useApi();
- const { trigger, isMutating } = useSWRMutation(
- api.endpoint ? [api.endpoint, "/flows"] : null,
- ([, path], { arg }) =>
- api.put(`${path}/${arg.flowId}/tags/flow_status`, arg.status).then(
- (response) => response.data
- )
- );
-
- return {
- update: trigger,
- isUpdating: isMutating,
- };
-};
diff --git a/src/hooks/useSegments.jsx b/src/hooks/useSegments.jsx
index 605b9d12..0c9c6094 100644
--- a/src/hooks/useSegments.jsx
+++ b/src/hooks/useSegments.jsx
@@ -1,20 +1,44 @@
import { useApi } from "@/hooks/useApi";
import useSWR from "swr";
+import buildQuery from "@/utils/buildQuery";
import paginationFetcher from "@/utils/paginationFetcher";
+import { useCapabilities } from "@/hooks/useService";
+import { TAMS_POLLING_INTERVAL } from "@/constants";
-export const useLastN = (flowId, n) => {
+/**
+ * 8.2 lets clients narrow the get_urls returned for a Segment to storage
+ * backends carrying a given tag. `{ name, value }` filters on the value;
+ * `{ name }` alone filters on the tag being present.
+ */
+const storageBackendTagParams = (storageBackendTag, supported) => {
+ if (!supported || !storageBackendTag?.name) return {};
+ const { name, value } = storageBackendTag;
+ return value
+ ? { [`storage_backend_tag.${name}`]: value }
+ : { [`storage_backend_tag_exists.${name}`]: true };
+};
+
+export const useLastN = (flowId, n, storageBackendTag) => {
const api = useApi();
+ const { storageBackendTags, resolved } = useCapabilities();
+
+ const path = buildQuery(`/flows/${flowId}/segments`, {
+ accept_get_urls: "",
+ reverse_order: true,
+ include_object_timerange: true,
+ ...storageBackendTagParams(storageBackendTag, storageBackendTags),
+ });
+
const { data, mutate, error, isLoading, isValidating } = useSWR(
- `/flows/${flowId}/segments`,
- (path) =>
- paginationFetcher(`${path}?accept_get_urls=&reverse_order=true&include_object_timerange=true`, n, api),
+ api.endpoint && resolved ? [api.endpoint, path, n] : null,
+ () => paginationFetcher(path, n, api),
{
- refreshInterval: 3000,
+ refreshInterval: TAMS_POLLING_INTERVAL,
}
);
return {
- segments: data,
+ segments: data?.items,
mutate,
isLoading,
isValidating,
@@ -24,20 +48,19 @@ export const useLastN = (flowId, n) => {
export const useSegments = (flowId, timerange, maxResults = 3000) => {
const api = useApi();
+ const path = buildQuery(`/flows/${flowId}/segments`, {
+ timerange: timerange || undefined,
+ reverse_order: false,
+ limit: 300,
+ });
+
const { data, mutate, error, isLoading, isValidating } = useSWR(
- `/flows/${flowId}/segments`,
- (path) =>
- paginationFetcher(
- `${path}${
- timerange ? `?timerange=${timerange}` : ""
- }&reverse_order=false&limit=300`,
- maxResults,
- api
- )
+ [`/flows/${flowId}/segments`, path, maxResults],
+ () => paginationFetcher(path, maxResults, api)
);
return {
- segments: data,
+ segments: data?.items,
mutate,
isLoading,
isValidating,
@@ -59,7 +82,7 @@ export const useFlowsSegments = (flows, timerange, maxResults = 3000) => {
const responses = await Promise.all(
paths.map((path) => paginationFetcher(path, maxResults, api))
);
- return responses;
+ return responses.map((response) => response.items);
}
);
diff --git a/src/hooks/useService.jsx b/src/hooks/useService.jsx
new file mode 100644
index 00000000..bca346fd
--- /dev/null
+++ b/src/hooks/useService.jsx
@@ -0,0 +1,141 @@
+import useSWR from "swr";
+import { useApi } from "@/hooks/useApi";
+import { getCapabilities } from "@/utils/capabilities";
+
+// GET /service is small and effectively static for a given store, so it is
+// fetched once and cached rather than polled like the resource listings.
+export const useService = () => {
+ const api = useApi();
+ const { data, error, isLoading } = useSWR(
+ api.endpoint ? [api.endpoint, "/service"] : null,
+ ([, path]) => api.get(path),
+ {
+ revalidateOnFocus: false,
+ revalidateIfStale: false,
+ // This one request gates every 8.2 feature, so a transient failure must
+ // not pin the session to the 8.0 baseline until the user reloads. Capped
+ // so a permanently unreachable store settles instead of retrying forever.
+ shouldRetryOnError: true,
+ errorRetryCount: 5,
+ }
+ );
+
+ return { service: data?.data, isLoading, error };
+};
+
+/**
+ * Feature flags for the active store, derived from its advertised api_version.
+ *
+ * `resolved` tells callers whether the version is known yet. Listing hooks wait
+ * for it before building a query so that a store which turns out to be 8.2 is
+ * not first queried with 8.0 parameters and then immediately queried again.
+ * A store that does not serve /service resolves to the 8.0 baseline.
+ *
+ * A /service that errors resolves to that same baseline so the tool stays
+ * usable, but `detectionFailed` distinguishes it from a store that genuinely
+ * advertises an older version. Callers that state the store's version must
+ * check it — "we could not ask" is not the same claim as "the store said 8.0".
+ */
+export const useCapabilities = () => {
+ const { service, isLoading, error } = useService();
+ const capabilities = getCapabilities(service?.api_version);
+
+ return {
+ ...capabilities,
+ service,
+ error,
+ detectionFailed: Boolean(error),
+ // An error is a settled answer. Without this, each retry flips isLoading
+ // back to true and the UI pulses between a spinner and the honest error.
+ resolved: !isLoading || Boolean(error),
+ };
+};
+
+/**
+ * Storage Backends and, from 8.2, the tags attached to them. The tag names and
+ * values are surfaced so views can offer the storage_backend_tag filters
+ * without asking the user to type identifiers from memory.
+ */
+export const useStorageBackends = () => {
+ const api = useApi();
+ const {
+ storageBackendTags: supported,
+ resolved,
+ detectionFailed,
+ } = useCapabilities();
+
+ // Gated on `supported` so an 8.0 store is not asked for an endpoint it does
+ // not have on every mount.
+ const { data, error, isLoading } = useSWR(
+ api.endpoint && resolved && supported
+ ? [api.endpoint, "/service/storage-backends"]
+ : null,
+ ([, path]) => api.get(path),
+ { revalidateOnFocus: false, shouldRetryOnError: false }
+ );
+
+ const backends = Array.isArray(data?.data) ? data.data : [];
+
+ // Union of tag names to the values seen across all backends.
+ const tagValues = new Map();
+ backends.forEach((backend) => {
+ Object.entries(backend.tags ?? {}).forEach(([name, value]) => {
+ const values = tagValues.get(name) ?? new Set();
+ (Array.isArray(value) ? value : [value]).forEach((entry) =>
+ values.add(String(entry))
+ );
+ tagValues.set(name, values);
+ });
+ });
+
+ return {
+ backends,
+ tags: [...tagValues.entries()].map(([name, values]) => ({
+ name,
+ values: [...values],
+ })),
+ supported,
+ resolved,
+ detectionFailed,
+ isLoading,
+ error,
+ };
+};
+
+/** Flow Profiles, added in 8.2 at /service/profiles. */
+export const useProfiles = () => {
+ const api = useApi();
+ const { profiles: supported, resolved, detectionFailed } = useCapabilities();
+
+ const { data, error, isLoading } = useSWR(
+ api.endpoint && resolved && supported
+ ? [api.endpoint, "/service/profiles"]
+ : null,
+ ([, path]) => api.get(path),
+ { revalidateOnFocus: false, shouldRetryOnError: false }
+ );
+
+ return {
+ profiles: Array.isArray(data?.data) ? data.data : undefined,
+ supported,
+ resolved,
+ detectionFailed,
+ isLoading,
+ error,
+ };
+};
+
+export const useProfile = (profileId) => {
+ const api = useApi();
+ const { profiles: supported, resolved, detectionFailed } = useCapabilities();
+
+ const { data, error, isLoading } = useSWR(
+ api.endpoint && resolved && supported && profileId
+ ? [api.endpoint, "/service/profiles", profileId]
+ : null,
+ ([, path, id]) => api.get(`${path}/${id}`),
+ { revalidateOnFocus: false, shouldRetryOnError: false }
+ );
+
+ return { profile: data?.data, supported, resolved, detectionFailed, isLoading, error };
+};
diff --git a/src/hooks/useSources.jsx b/src/hooks/useSources.jsx
index 0b4cace9..1cd8b2a2 100644
--- a/src/hooks/useSources.jsx
+++ b/src/hooks/useSources.jsx
@@ -1,24 +1,22 @@
import { useApi } from "@/hooks/useApi";
import useSWR from "swr";
+import { useCapabilities } from "@/hooks/useService";
+import useEntityListing from "@/hooks/useEntityListing";
+import buildQuery from "@/utils/buildQuery";
import paginationFetcher from "@/utils/paginationFetcher";
+import {
+ SOURCES_POLLING_INTERVAL,
+ TAMS_PAGE_LIMIT,
+ TAMS_POLLING_INTERVAL,
+} from "@/constants";
-export const useSources = () => {
- const api = useApi();
- const { data, mutate, error, isLoading, isValidating } = useSWR(
- api.endpoint ? [api.endpoint, "/sources?limit=300"] : null,
- ([, path]) => paginationFetcher(path, null, api),
- {
- refreshInterval: 5000,
- }
- );
+export const useSources = (options = {}) => {
+ const { items, ...rest } = useEntityListing("sources", {
+ refreshInterval: SOURCES_POLLING_INTERVAL,
+ ...options,
+ });
- return {
- sources: data,
- mutate,
- isLoading,
- isValidating,
- error,
- };
+ return { sources: items, ...rest };
};
export const useSource = (sourceId) => {
@@ -31,9 +29,9 @@ export const useSource = (sourceId) => {
isValidating,
} = useSWR(
api.endpoint ? [api.endpoint, "/sources", sourceId] : null,
- ([, path, sourceId]) => api.get(`${path}/${sourceId}`),
+ ([, path, id]) => api.get(`${path}/${id}`),
{
- refreshInterval: 3000,
+ refreshInterval: TAMS_POLLING_INTERVAL,
}
);
@@ -56,9 +54,9 @@ export const useSourceFlows = (sourceId) => {
isValidating,
} = useSWR(
api.endpoint ? [api.endpoint, "/flows", sourceId] : null,
- ([, path, sourceId]) => api.get(`${path}?source_id=${sourceId}`),
+ ([, path, id]) => api.get(`${path}?source_id=${id}`),
{
- refreshInterval: 3000,
+ refreshInterval: TAMS_POLLING_INTERVAL,
}
);
@@ -70,3 +68,33 @@ export const useSourceFlows = (sourceId) => {
error,
};
};
+
+/**
+ * Fetch the Sources or Flows collected by `parentId` in a single request.
+ *
+ * Needs the 8.2 collected_by_ids filter; without it the caller only has the
+ * ids and roles carried in the parent's own collection list.
+ */
+export const useCollectionMembers = (entityType, parentId) => {
+ const api = useApi();
+ const { collectedByIds: supported, resolved } = useCapabilities();
+
+ const path = buildQuery(`/${entityType}`, {
+ limit: TAMS_PAGE_LIMIT,
+ collected_by_ids: parentId,
+ });
+
+ const { data, error, isLoading } = useSWR(
+ api.endpoint && resolved && supported && parentId
+ ? [api.endpoint, "collected-by", path]
+ : null,
+ () => paginationFetcher(path, TAMS_PAGE_LIMIT, api)
+ );
+
+ return {
+ members: data?.items,
+ supported: supported && resolved,
+ isLoading,
+ error,
+ };
+};
diff --git a/src/hooks/useWebhooks.jsx b/src/hooks/useWebhooks.jsx
index 86860776..cdce497d 100644
--- a/src/hooks/useWebhooks.jsx
+++ b/src/hooks/useWebhooks.jsx
@@ -23,7 +23,7 @@ export const useWebhooks = () => {
);
return {
- webhooks: data,
+ webhooks: data?.items,
mutate,
isLoading,
isValidating,
diff --git a/src/stores/usePreferencesStore.jsx b/src/stores/usePreferencesStore.jsx
index 25b74cde..1be3b5f2 100644
--- a/src/stores/usePreferencesStore.jsx
+++ b/src/stores/usePreferencesStore.jsx
@@ -1,7 +1,153 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { Mode } from "@cloudscape-design/global-styles";
-import { PAGE_SIZE } from "@/constants";
+import { PAGE_SIZE, VIEW_MODE } from "@/constants";
+
+const DEFAULT_FLOWS_COLUMNS = [
+ { id: "id", visible: true },
+ { id: "label", visible: true },
+ { id: "description", visible: true },
+ { id: "format", visible: true },
+ { id: "created_by", visible: false },
+ { id: "updated_by", visible: false },
+ { id: "created", visible: true },
+ { id: "status", visible: true },
+ { id: "editorial_purpose", visible: false },
+ { id: "init_segments", visible: false },
+ { id: "profile_id", visible: false },
+ { id: "tags", visible: false },
+ { id: "flow_collection", visible: false },
+ { id: "collected_by", visible: false },
+ { id: "source_id", visible: false },
+ { id: "metadata_version", visible: false },
+ { id: "generation", visible: false },
+ { id: "metadata_updated", visible: false },
+ { id: "read_only", visible: false },
+ { id: "codec", visible: false },
+ { id: "container", visible: false },
+ { id: "avg_bit_rate", visible: false },
+ { id: "max_bit_rate", visible: false },
+];
+
+const DEFAULT_SOURCES_COLUMNS = [
+ { id: "id", visible: true },
+ { id: "label", visible: true },
+ { id: "description", visible: true },
+ { id: "format", visible: true },
+ { id: "created_by", visible: false },
+ { id: "updated_by", visible: false },
+ { id: "created", visible: true },
+ { id: "updated", visible: false },
+ { id: "editorial_purpose", visible: false },
+ { id: "tags", visible: false },
+ { id: "source_collection", visible: false },
+ { id: "collected_by", visible: false },
+];
+
+const DEFAULT_WEBHOOKS_COLUMNS = [
+ { id: "id", visible: true },
+ { id: "url", visible: true },
+ { id: "api_key_name", visible: false },
+ { id: "status", visible: true },
+ { id: "events", visible: true },
+ { id: "flow_ids", visible: false },
+ { id: "source_ids", visible: false },
+ { id: "flow_collected_by_ids", visible: false },
+ { id: "source_collected_by_ids", visible: false },
+ { id: "accept_get_urls", visible: false },
+ { id: "accept_storage_ids", visible: false },
+ { id: "include_object_timerange", visible: false },
+ { id: "presigned", visible: false },
+ { id: "verbose_storage", visible: false },
+];
+
+const DEFAULT_PROFILES_COLUMNS = [
+ { id: "id", visible: true },
+ { id: "label", visible: true },
+ { id: "description", visible: true },
+ { id: "format", visible: true },
+ { id: "codec", visible: true },
+ { id: "container", visible: false },
+ { id: "created_by", visible: false },
+ { id: "created", visible: true },
+];
+
+const DEFAULT_SEGMENTS_COLUMNS = [
+ { id: "id", visible: true },
+ { id: "timerange", visible: true },
+ { id: "ts_offset", visible: false },
+ { id: "last_duration", visible: false },
+ { id: "object_timerange", visible: false },
+ { id: "init_object", visible: false },
+ { id: "sample_offset", visible: false },
+ { id: "sample_count", visible: false },
+ { id: "key_frame_count", visible: false },
+ { id: "timerange_start", visible: true },
+ { id: "timerange_end", visible: true },
+];
+
+const DEFAULT_FFMPEG_EXPORTS_COLUMNS = [
+ { id: "executionArn", visible: true },
+ { id: "timerange", visible: true },
+ { id: "flowIds", visible: true },
+ { id: "command", visible: false },
+ { id: "outputFormat", visible: true },
+ { id: "output", visible: true },
+ { id: "status", visible: true },
+ { id: "startDate", visible: false },
+ { id: "stopDate", visible: false },
+];
+
+const DEFAULT_FFMPEG_JOBS_COLUMNS = [
+ { id: "id", visible: true },
+ { id: "sourceTimerange", visible: true },
+ { id: "command", visible: true },
+ { id: "outputFormat", visible: false },
+ { id: "outputFlow", visible: true },
+ { id: "status", visible: true },
+ { id: "startDate", visible: false },
+ { id: "stopDate", visible: false },
+];
+
+const DEFAULT_COLUMNS_BY_KEY = {
+ flowsPreferences: DEFAULT_FLOWS_COLUMNS,
+ sourcesPreferences: DEFAULT_SOURCES_COLUMNS,
+ webhooksPreferences: DEFAULT_WEBHOOKS_COLUMNS,
+ profilesPreferences: DEFAULT_PROFILES_COLUMNS,
+ segmentsPreferences: DEFAULT_SEGMENTS_COLUMNS,
+ ffmpegExportsPreferences: DEFAULT_FFMPEG_EXPORTS_COLUMNS,
+ ffmpegJobsPreferences: DEFAULT_FFMPEG_JOBS_COLUMNS,
+};
+
+/**
+ * Column preferences are persisted per browser, so columns added by a release
+ * are absent from a returning user's stored list and would never appear. Keep
+ * the user's order and visibility choices and append whatever is new.
+ */
+const mergeColumns = (persisted, defaults) => {
+ if (!Array.isArray(persisted)) return defaults;
+ // Drop malformed entries rather than throwing on column.id: this now runs on
+ // every rehydrate, and a throw here is swallowed by zustand and silently
+ // resets every preference to defaults.
+ const clean = persisted.filter(
+ (column) => column && typeof column.id === "string"
+ );
+ const known = new Set(clean.map((column) => column.id));
+ return [...clean, ...defaults.filter((column) => !known.has(column.id))];
+};
+
+const withNewColumns = (state) => {
+ if (!state) return state;
+ const merged = { ...state };
+ Object.entries(DEFAULT_COLUMNS_BY_KEY).forEach(([key, defaults]) => {
+ if (!merged[key]) return;
+ merged[key] = {
+ ...merged[key],
+ contentDisplay: mergeColumns(merged[key].contentDisplay, defaults),
+ };
+ });
+ return merged;
+};
const usePreferencesStore = create(
persist(
@@ -17,124 +163,74 @@ const usePreferencesStore = create(
setSourcesShowHierarchy: (hierarchy) =>
set({ sourcesShowHierarchy: hierarchy }),
+ sourcesViewMode: VIEW_MODE.ALL,
+ setSourcesViewMode: (viewMode) => set({ sourcesViewMode: viewMode }),
+
+ flowsViewMode: VIEW_MODE.ALL,
+ setFlowsViewMode: (viewMode) => set({ flowsViewMode: viewMode }),
+
flowsPreferences: {
pageSize: PAGE_SIZE,
- contentDisplay: [
- { id: "id", visible: true },
- { id: "label", visible: true },
- { id: "description", visible: true },
- { id: "format", visible: true },
- { id: "created_by", visible: false },
- { id: "updated_by", visible: false },
- { id: "created", visible: true },
- { id: "tags", visible: false },
- { id: "flow_collection", visible: false },
- { id: "collected_by", visible: false },
- { id: "source_id", visible: false },
- { id: "metadata_version", visible: false },
- { id: "generation", visible: false },
- { id: "metadata_updated", visible: false },
- { id: "read_only", visible: false },
- { id: "codec", visible: false },
- { id: "container", visible: false },
- { id: "avg_bit_rate", visible: false },
- { id: "max_bit_rate", visible: false },
- ],
+ contentDisplay: DEFAULT_FLOWS_COLUMNS,
},
setFlowsPreferences: (preferences) =>
set({ flowsPreferences: preferences }),
sourcesPreferences: {
pageSize: PAGE_SIZE,
- contentDisplay: [
- { id: "id", visible: true },
- { id: "label", visible: true },
- { id: "description", visible: true },
- { id: "format", visible: true },
- { id: "created_by", visible: false },
- { id: "updated_by", visible: false },
- { id: "created", visible: true },
- { id: "updated", visible: false },
- { id: "tags", visible: false },
- { id: "source_collection", visible: false },
- { id: "collected_by", visible: false },
- ],
+ contentDisplay: DEFAULT_SOURCES_COLUMNS,
},
setSourcesPreferences: (preferences) =>
set({ sourcesPreferences: preferences }),
webhooksPreferences: {
pageSize: PAGE_SIZE,
- contentDisplay: [
- { id: "id", visible: true },
- { id: "url", visible: true },
- { id: "api_key_name", visible: false },
- { id: "status", visible: true },
- { id: "events", visible: true },
- { id: "flow_ids", visible: false },
- { id: "source_ids", visible: false },
- { id: "flow_collected_by_ids", visible: false },
- { id: "source_collected_by_ids", visible: false },
- { id: "accept_get_urls", visible: false },
- { id: "accept_storage_ids", visible: false },
- { id: "presigned", visible: false },
- { id: "verbose_storage", visible: false },
- ],
+ contentDisplay: DEFAULT_WEBHOOKS_COLUMNS,
},
setWebhooksPreferences: (preferences) =>
set({ webhooksPreferences: preferences }),
+ profilesPreferences: {
+ pageSize: PAGE_SIZE,
+ contentDisplay: DEFAULT_PROFILES_COLUMNS,
+ },
+ setProfilesPreferences: (preferences) =>
+ set({ profilesPreferences: preferences }),
+
segmentsPreferences: {
- contentDisplay: [
- { id: "id", visible: true },
- { id: "timerange", visible: true },
- { id: "ts_offset", visible: false },
- { id: "last_duration", visible: false },
- { id: "sample_offset", visible: false },
- { id: "sample_count", visible: false },
- { id: "key_frame_count", visible: false },
- { id: "timerange_start", visible: true },
- { id: "timerange_end", visible: true },
- ],
+ contentDisplay: DEFAULT_SEGMENTS_COLUMNS,
},
setSegmentsPreferences: (preferences) =>
set({ segmentsPreferences: preferences }),
ffmpegExportsPreferences: {
pageSize: PAGE_SIZE,
- contentDisplay: [
- { id: "executionArn", visible: true },
- { id: "timerange", visible: true },
- { id: "flowIds", visible: true },
- { id: "command", visible: false },
- { id: "outputFormat", visible: true },
- { id: "output", visible: true },
- { id: "status", visible: true },
- { id: "startDate", visible: false },
- { id: "stopDate", visible: false },
- ],
+ contentDisplay: DEFAULT_FFMPEG_EXPORTS_COLUMNS,
},
setFfmpegExportsPreferences: (preferences) =>
set({ ffmpegExportsPreferences: preferences }),
ffmpegJobsPreferences: {
pageSize: PAGE_SIZE,
- contentDisplay: [
- { id: "id", visible: true },
- { id: "sourceTimerange", visible: true },
- { id: "command", visible: true },
- { id: "outputFormat", visible: false },
- { id: "outputFlow", visible: true },
- { id: "status", visible: true },
- { id: "startDate", visible: false },
- { id: "stopDate", visible: false },
- ],
+ contentDisplay: DEFAULT_FFMPEG_JOBS_COLUMNS,
},
setFfmpegJobsPreferences: (preferences) =>
set({ ffmpegJobsPreferences: preferences }),
}),
{
name: "tams-ui-preferences",
+ version: 1,
+ // merge runs on every rehydrate, so a column added by a later release
+ // reaches returning users without anyone having to bump `version`.
+ // mergeColumns only appends unknown ids, so this is idempotent.
+ merge: (persisted, current) => ({ ...current, ...withNewColumns(persisted) }),
+ // Still needed: the previous build stored version 0 (zustand's default),
+ // and on a mismatch with no migrate function zustand discards the whole
+ // persisted blob rather than falling through to merge.
+ migrate: withNewColumns,
+ onRehydrateStorage: () => (_state, error) => {
+ if (error) console.error("Preferences could not be restored", error);
+ },
}
)
);
diff --git a/src/utils/buildQuery.js b/src/utils/buildQuery.js
new file mode 100644
index 00000000..303eee86
--- /dev/null
+++ b/src/utils/buildQuery.js
@@ -0,0 +1,16 @@
+/**
+ * Build a TAMS query string. Keys with an `undefined` or `null` value are
+ * dropped; an empty string is kept, because TAMS gives it meaning of its own —
+ * `collected_by_ids=` selects resources that are in no collection at all.
+ */
+const buildQuery = (path, params = {}) => {
+ const search = new URLSearchParams();
+ Object.entries(params).forEach(([key, value]) => {
+ if (value === undefined || value === null) return;
+ search.append(key, typeof value === "boolean" ? String(value) : value);
+ });
+ const query = search.toString();
+ return query ? `${path}?${query}` : path;
+};
+
+export default buildQuery;
diff --git a/src/utils/capabilities.js b/src/utils/capabilities.js
new file mode 100644
index 00000000..941536bf
--- /dev/null
+++ b/src/utils/capabilities.js
@@ -0,0 +1,33 @@
+// TAMS features tamstool uses that were introduced after 8.0. Stores advertise
+// their spec level at GET /service as `api_version` (e.g. "8.2"), so we gate on
+// that rather than probing each endpoint. Anything we cannot read is treated as
+// the 8.0 baseline, which is the behaviour tamstool had before 8.2 support.
+export const BASELINE_API_VERSION = "8.0";
+
+export const parseApiVersion = (apiVersion) => {
+ if (typeof apiVersion !== "string") return null;
+ const match = /^(\d+)\.(\d+)$/.exec(apiVersion.trim());
+ return match ? { major: Number(match[1]), minor: Number(match[2]) } : null;
+};
+
+export const isAtLeast = (apiVersion, major, minor) => {
+ const parsed = parseApiVersion(apiVersion) ?? parseApiVersion(BASELINE_API_VERSION);
+ return parsed.major > major || (parsed.major === major && parsed.minor >= minor);
+};
+
+export const getCapabilities = (apiVersion) => {
+ const has82 = isAtLeast(apiVersion, 8, 2);
+ return {
+ apiVersion: parseApiVersion(apiVersion) ? apiVersion.trim() : null,
+ // 8.2: sort_by / reverse_order on Source and Flow listings
+ sortBy: has82,
+ // 8.2: collected_by_ids on /sources and /flows (empty value = top-level only)
+ collectedByIds: has82,
+ // 8.2: flow_status tag promoted to the core Flow `status` attribute
+ flowStatus: has82,
+ // 8.2: /service/profiles and the profile_id Flow filter
+ profiles: has82,
+ // 8.2: storage_backend_tag.{name} filters on segments
+ storageBackendTags: has82,
+ };
+};
diff --git a/src/utils/editorialPurpose.js b/src/utils/editorialPurpose.js
new file mode 100644
index 00000000..46b24f34
--- /dev/null
+++ b/src/utils/editorialPurpose.js
@@ -0,0 +1,50 @@
+// TAMS 8.2 makes the collection `role` optional and points clients at the
+// `editorial_purpose` tag instead (AppNote0025). Values are freeform, but the
+// app note publishes a recommended list; these are the ones tamstool reasons
+// about when it has to pick a Flow or Source out of a collection itself.
+export const EDITORIAL_PURPOSE = {
+ PROGRAMME: "programme",
+ PROGRAMME_SIGNED: "programme_signed",
+ CLEANFEED: "cleanfeed",
+};
+
+// Preference order when looking for the main video of a multi-Source.
+const VIDEO_PURPOSES = [
+ EDITORIAL_PURPOSE.PROGRAMME,
+ EDITORIAL_PURPOSE.CLEANFEED,
+ EDITORIAL_PURPOSE.PROGRAMME_SIGNED,
+];
+
+export const VIDEO_FORMAT = "urn:x-nmos:format:video";
+
+/** Read the editorial_purpose tag from a Source or Flow. */
+export const getEditorialPurpose = (entity) => {
+ const tag = entity?.tags?.editorial_purpose;
+ if (typeof tag === "string" && tag) return tag;
+ if (Array.isArray(tag)) return tag[0] ?? null;
+ return null;
+};
+
+/**
+ * Pick the video entity from a set of collected Sources or Flows.
+ *
+ * `role === "video"` is checked first because it needs no extra request, but
+ * 8.2 stores may omit `role` entirely, so fall back to the editorial_purpose
+ * tag and finally to the NMOS video format.
+ */
+export const findVideoMember = (collection = [], entitiesById = new Map()) => {
+ const byRole = collection.find((member) => member?.role === "video");
+ if (byRole) return byRole.id;
+
+ for (const purpose of VIDEO_PURPOSES) {
+ const match = collection.find(
+ (member) => getEditorialPurpose(entitiesById.get(member.id)) === purpose
+ );
+ if (match) return match.id;
+ }
+
+ const byFormat = collection.find(
+ (member) => entitiesById.get(member.id)?.format === VIDEO_FORMAT
+ );
+ return byFormat?.id ?? null;
+};
diff --git a/src/utils/fetchEntityTree.js b/src/utils/fetchEntityTree.js
new file mode 100644
index 00000000..e54189af
--- /dev/null
+++ b/src/utils/fetchEntityTree.js
@@ -0,0 +1,85 @@
+import buildQuery from "@/utils/buildQuery";
+import paginationFetcher from "@/utils/paginationFetcher";
+import { TAMS_PAGE_LIMIT } from "@/constants";
+
+// 40 ids x 37 chars is ~1.5 kB of query string, well inside the 8 kB request
+// line that proxies commonly cap at.
+const IDS_PER_REQUEST = 40;
+// Collections nested deeper than this are not loaded; the caller is told via
+// `truncated` so the gap is visible rather than looking like an empty branch.
+export const MAX_DEPTH = 4;
+
+const batchIds = (ids, size) => {
+ const batches = [];
+ for (let index = 0; index < ids.length; index += size) {
+ batches.push(ids.slice(index, index + size));
+ }
+ return batches;
+};
+
+/**
+ * Load a Source or Flow collection tree using the 8.2 `collected_by_ids` filter.
+ *
+ * Starts from the top-level entities (`collected_by_ids=` selects those in no
+ * collection) and descends one level at a time, asking for every id at that
+ * level in a single request. Cloudscape only renders an expand control for rows
+ * whose children are already in the item set, so the tree is fetched by depth
+ * rather than on expand — that is still a handful of requests instead of one
+ * per collection.
+ *
+ * `maxResults` applies per request, so `items` can exceed it once descendants
+ * are included. `truncated` reports that the tree is incomplete, either because
+ * the depth cap stopped the descent or because a level had more children than
+ * one page.
+ */
+const fetchEntityTree = async (entityType, api, maxResults = TAMS_PAGE_LIMIT) => {
+ const rootPath = buildQuery(`/${entityType}`, {
+ limit: TAMS_PAGE_LIMIT,
+ collected_by_ids: "",
+ });
+ const roots = await paginationFetcher(rootPath, maxResults, api);
+
+ const items = [...roots.items];
+ const seen = new Set(items.map((item) => item.id));
+ let frontier = items.map((item) => item.id);
+ let childrenTruncated = false;
+
+ for (let depth = 0; depth < MAX_DEPTH && frontier.length > 0; depth += 1) {
+ const batches = batchIds(frontier, IDS_PER_REQUEST);
+ const responses = await Promise.all(
+ batches.map((batch) =>
+ paginationFetcher(
+ buildQuery(`/${entityType}`, {
+ limit: TAMS_PAGE_LIMIT,
+ collected_by_ids: batch.join(","),
+ }),
+ maxResults,
+ api
+ )
+ )
+ );
+
+ if (responses.some((response) => response.hasMore)) childrenTruncated = true;
+
+ const fresh = responses
+ .flatMap((response) => response.items)
+ .filter((item) => !seen.has(item.id));
+
+ fresh.forEach((item) => seen.add(item.id));
+ items.push(...fresh);
+ frontier = fresh.map((item) => item.id);
+ }
+
+ // A non-empty frontier means the depth cap stopped the descent before those
+ // items were probed for children. They may be leaves, so this reports "not
+ // verified" rather than "definitely more" — the copy is hedged to match.
+ const depthUnverified = frontier.length > 0;
+
+ return {
+ items,
+ hasMore: roots.hasMore || childrenTruncated,
+ truncated: depthUnverified || childrenTruncated,
+ };
+};
+
+export default fetchEntityTree;
diff --git a/src/utils/flowStatus.js b/src/utils/flowStatus.js
new file mode 100644
index 00000000..4feb1dd2
--- /dev/null
+++ b/src/utils/flowStatus.js
@@ -0,0 +1,66 @@
+// TAMS 8.2 promotes the `flow_status` tag to a core Flow `status` attribute.
+export const FLOW_STATUS = {
+ AWAITING_CONTENT: "awaiting_content",
+ INGESTING: "ingesting",
+ REPLICATION_IN_PROGRESS: "replication_in_progress",
+ CLOSED_COMPLETE: "closed_complete",
+};
+
+export const FLOW_STATUS_VALUES = Object.values(FLOW_STATUS);
+
+// An ingest that dies without closing its Flow leaves `status` reading
+// "ingesting" forever, so recent segment activity is still required before we
+// present a Flow as live.
+export const GROWING_STALE_MS = 10 * 60 * 1000;
+
+/**
+ * Read a Flow's status, preferring the 8.2 attribute and falling back to the
+ * deprecated tag for 8.0/8.1 stores and for clients that have not migrated.
+ * The tag is a freeform string or a list of strings.
+ */
+export const getFlowStatus = (flow) => {
+ if (typeof flow?.status === "string" && flow.status) return flow.status;
+
+ const tag = flow?.tags?.flow_status;
+ if (typeof tag === "string" && tag) return tag;
+ if (Array.isArray(tag)) {
+ return FLOW_STATUS_VALUES.find((value) => tag.includes(value)) ?? tag[0] ?? null;
+ }
+ return null;
+};
+
+/**
+ * Whether a Flow is actively receiving content. The spec is explicit that
+ * `status` is client-maintained and therefore indicative rather than
+ * authoritative, so an ingesting Flow that has not moved recently is treated as
+ * abandoned.
+ *
+ * `segments_updated` measures activity directly. Where it is absent — a Flow
+ * that has not received a segment yet, or a store that does not report it —
+ * `created` stands in as a grace window: an ingest that was announced but never
+ * started still ages out, rather than reporting itself live forever. That is
+ * the abandoned-ingest case this guard exists for, so the fallback is doing
+ * real work and is not a substitute activity signal.
+ *
+ * Note that paginationFetcher strips `segments_updated`, so a Flow from a
+ * listing always falls through to `created`; pass a Flow from the detail
+ * endpoint where the distinction matters.
+ */
+export const isFlowGrowing = (flow, now = Date.now()) => {
+ const status = getFlowStatus(flow);
+ if (status === FLOW_STATUS.CLOSED_COMPLETE) return false;
+ if (
+ status !== FLOW_STATUS.INGESTING &&
+ status !== FLOW_STATUS.REPLICATION_IN_PROGRESS
+ ) {
+ return false;
+ }
+
+ const lastActivity = flow?.segments_updated ?? flow?.created;
+ if (!lastActivity) return true;
+
+ const timestamp = new Date(lastActivity).getTime();
+ if (Number.isNaN(timestamp)) return true;
+ return now - timestamp < GROWING_STALE_MS;
+};
+
diff --git a/src/utils/initSegments.js b/src/utils/initSegments.js
new file mode 100644
index 00000000..515e4c50
--- /dev/null
+++ b/src/utils/initSegments.js
@@ -0,0 +1,50 @@
+import { EMPTY_PATTERNS, TIMERANGE_REGEX } from "@/utils/timerange";
+
+/**
+ * Two conventions exist for init segments.
+ *
+ * TAMS 8.2 declares them on the Flow as `essence_parameters.init_segments` and
+ * hangs an `init_object` off each segment. Pre-8.2 stores instead name the init
+ * object in the `tags.init_segment` Flow tag and list it at the head of the
+ * segment listing as an entry with a zero-length timerange.
+ */
+export const hasInitSegments = (flow) => {
+ const declared = flow?.essence_parameters?.init_segments;
+ // `== null` so an explicit null still reads as "the store did not say",
+ // which is the blank the Flows column reserves for unknown.
+ if (declared != null) return Boolean(declared);
+
+ const tag = flow?.tags?.init_segment;
+ if (Array.isArray(tag)) return tag.length > 0;
+ return Boolean(tag);
+};
+
+/**
+ * Whether a segment listing entry is the pre-8.2 init object rather than media.
+ * Such an entry carries no media, so it is not a segment the user can play.
+ *
+ * The bounds are read from the string rather than from parseTimerange, which
+ * collapses "zero-length", "inverted" and "did not parse" onto the same 0n..0n
+ * result — trusting it would badge a segment whose timerange is merely corrupt.
+ */
+export const isInitSegmentEntry = (segment) => {
+ if (segment?.init_object) return false;
+
+ const { timerange } = segment ?? {};
+ if (typeof timerange !== "string" || !timerange) return false;
+ if (EMPTY_PATTERNS.has(timerange)) return true;
+
+ const match = TIMERANGE_REGEX.exec(timerange);
+ if (!match) return false;
+
+ const { startSeconds, startNanos, endSeconds, endNanos } = match.groups;
+ const hasStart = startSeconds != null || startNanos != null;
+ const hasEnd = endSeconds != null || endNanos != null;
+ if (!hasStart) return false;
+
+ // A bare timestamp with no range separator is a single point, so zero-length.
+ if (!timerange.includes("_")) return true;
+ if (!hasEnd) return false;
+
+ return startSeconds === endSeconds && startNanos === endNanos;
+};
diff --git a/src/utils/paginationFetcher.js b/src/utils/paginationFetcher.js
index 248151b5..c6a5ad97 100644
--- a/src/utils/paginationFetcher.js
+++ b/src/utils/paginationFetcher.js
@@ -1,3 +1,10 @@
+/**
+ * Follow TAMS `Link: rel="next"` cursors, accumulating records.
+ *
+ * Returns `hasMore` so callers can tell a complete result set apart from one
+ * truncated by `maxResults` — with 8.2 server-side sorting the first page is
+ * usually enough, and the views offer to load further pages on demand.
+ */
const paginationFetcher = async (path, maxResults, api) => {
const { get, endpoint } = api;
let response = await get(path);
@@ -10,17 +17,27 @@ const paginationFetcher = async (path, maxResults, api) => {
while (response.nextLink && (!maxResults || records.length < maxResults)) {
const nextPath = response.nextLink.slice(endpoint.length);
response = await get(nextPath);
- if (Array.isArray(response.data)) {
- records = records.concat(response.data);
+ // Throw rather than skip: silently dropping a page yields a listing that
+ // looks complete, and callers now report completeness to the user.
+ if (!Array.isArray(response.data)) {
+ throw new Error("Unexpected response from TAMS store");
}
+ records = records.concat(response.data);
}
+ const truncated = Boolean(maxResults) && records.length > maxResults;
+ const hasMore = Boolean(response.nextLink) || truncated;
+
if (maxResults) {
records = records.slice(0, maxResults);
}
- // Remove segments_updated field from record if present. This is required to avoid excessive re-renders for the flows view.
- return records.map(({ segments_updated, ...remainder }) => remainder);
+ // segments_updated changes on every ingested segment. Dropping it from list
+ // responses keeps SWR from re-rendering the whole table every poll; views that
+ // need ingest recency read it from the flow detail endpoint instead.
+ const items = records.map(({ segments_updated, ...remainder }) => remainder);
+
+ return { items, hasMore };
};
export default paginationFetcher;
diff --git a/src/utils/timerange.js b/src/utils/timerange.js
index a45c78cc..48dfd070 100644
--- a/src/utils/timerange.js
+++ b/src/utils/timerange.js
@@ -2,7 +2,7 @@ import { DateTime } from "luxon";
const NANOS_PER_SECOND = 1_000_000_000n;
// Regex expression used to valdiate and parse the timerange string into it's components
-const TIMERANGE_REGEX =
+export const TIMERANGE_REGEX =
/^(?\[|\()?(?:(?-?(?:0|[1-9][0-9]*)):(?0|[1-9][0-9]{0,8}))?(?:_(?:(?-?(?:0|[1-9][0-9]*)):(?0|[1-9][0-9]{0,8}))?)?(?\]|\))?$/;
const INFINITE_PATTERNS = new Set([
"_",
@@ -15,7 +15,7 @@ const INFINITE_PATTERNS = new Set([
"_)",
"_]",
]);
-const EMPTY_PATTERNS = new Set([
+export const EMPTY_PATTERNS = new Set([
"",
"()",
"[]",
diff --git a/src/views/Diagram/index.jsx b/src/views/Diagram/index.jsx
index d42e67a1..363bd74f 100644
--- a/src/views/Diagram/index.jsx
+++ b/src/views/Diagram/index.jsx
@@ -15,6 +15,7 @@ import { buildStylesheet } from "./constants.js";
import { getElements } from "./utils";
import { useEffect } from "react";
import { useApi } from "@/hooks/useApi";
+import { useCapabilities } from "@/hooks/useService";
const Diagram = () => {
const { type, id } = useParams();
@@ -23,22 +24,37 @@ const Diagram = () => {
const [elements, setElements] = useState([]);
const [error, setError] = useState(null);
const api = useApi();
+ const { collectedByIds: canQueryCollections, resolved } = useCapabilities();
useEffect(() => {
+ // Without this gate the traversal runs twice on every open — once with
+ // canQueryCollections false (the slow per-member walk), then again once
+ // /service resolves — and the slower run can be the one that lands last.
+ if (!resolved) return undefined;
+
+ let cancelled = false;
const loadData = async () => {
setError(null);
try {
- const elems = await getElements(api, `/${type}/${id}`);
+ const elems = await getElements(
+ api,
+ `/${type}/${id}`,
+ canQueryCollections
+ );
+ if (cancelled) return;
setElements(elems);
cyRef.current?.fit();
} catch (err) {
- setError(err);
+ if (!cancelled) setError(err);
}
};
loadData();
- return () => cyRef.current?.removeAllListeners();
- }, [type, id, api]);
+ return () => {
+ cancelled = true;
+ cyRef.current?.removeAllListeners();
+ };
+ }, [type, id, api, canQueryCollections, resolved]);
const handleZoom = (action) => {
const zoom = cyRef.current?.zoom();
diff --git a/src/views/Diagram/utils.js b/src/views/Diagram/utils.js
index 6c0ce3e1..a0c66944 100644
--- a/src/views/Diagram/utils.js
+++ b/src/views/Diagram/utils.js
@@ -1,11 +1,40 @@
import { formatPrecedence, nodeSize } from "./constants";
+import { TAMS_PAGE_LIMIT } from "@/constants";
+
+/**
+ * Fetch every Source or Flow collected by `id` in one request and seed them into
+ * the prefetch cache, so the traversal below can walk the collection without a
+ * request per member. Needs the TAMS 8.2 collected_by_ids filter.
+ */
+const prefetchCollection = async (api, entityType, id, prefetched) => {
+ try {
+ const { data } = await api.get(
+ `/${entityType}?collected_by_ids=${id}&limit=${TAMS_PAGE_LIMIT}`
+ );
+ if (!Array.isArray(data)) return;
+ data.forEach((entity) =>
+ prefetched.set(`/${entityType}/${entity.id}`, entity)
+ );
+ } catch {
+ // Fall back to fetching members individually.
+ }
+};
-const getEntities = async (api, path, graph = {}) => {
+const getEntities = async (
+ api,
+ path,
+ graph = {},
+ canQueryCollections = false,
+ prefetched = new Map()
+) => {
// If we've already processed this path, return
if (graph[path]) return graph;
- // Fetch current path data
- const { data: resp } = await api.get(path);
+ // Fetch current path data, unless a collection query already returned it
+ let resp = prefetched.get(path);
+ if (!resp) {
+ ({ data: resp } = await api.get(path));
+ }
graph[path] = resp;
// Collect all promises for parallel execution
@@ -14,7 +43,9 @@ const getEntities = async (api, path, graph = {}) => {
if (resp.source_id) {
const sourcePath = `/sources/${resp.source_id}`;
if (!graph[sourcePath]) {
- promises.push(getEntities(api, sourcePath, graph));
+ promises.push(
+ getEntities(api, sourcePath, graph, canQueryCollections, prefetched)
+ );
}
} else {
// Handle source flows in parallel
@@ -32,23 +63,53 @@ const getEntities = async (api, path, graph = {}) => {
const type = resp.source_id ? "flows" : "sources";
const collectedPromises = resp.collected_by
.filter((collection) => !graph[`/${type}/${collection}`])
- .map((collection) => getEntities(api, `/${type}/${collection}`, graph));
+ .map((collection) =>
+ getEntities(
+ api,
+ `/${type}/${collection}`,
+ graph,
+ canQueryCollections,
+ prefetched
+ )
+ );
promises.push(...collectedPromises);
}
// Handle flow collections
- if (Array.isArray(resp.flow_collection)) {
+ if (Array.isArray(resp.flow_collection) && resp.flow_collection.length > 0) {
+ if (canQueryCollections) {
+ await prefetchCollection(api, "flows", resp.id, prefetched);
+ }
const flowPromises = resp.flow_collection
.filter((collection) => !graph[`/flows/${collection.id}`])
- .map((collection) => getEntities(api, `/flows/${collection.id}`, graph));
+ .map((collection) =>
+ getEntities(
+ api,
+ `/flows/${collection.id}`,
+ graph,
+ canQueryCollections,
+ prefetched
+ )
+ );
promises.push(...flowPromises);
}
// Handle source collections
- if (Array.isArray(resp.source_collection)) {
+ if (Array.isArray(resp.source_collection) && resp.source_collection.length > 0) {
+ if (canQueryCollections) {
+ await prefetchCollection(api, "sources", resp.id, prefetched);
+ }
const sourcePromises = resp.source_collection
.filter((collection) => !graph[`/sources/${collection.id}`])
- .map((collection) => getEntities(api, `/sources/${collection.id}`, graph));
+ .map((collection) =>
+ getEntities(
+ api,
+ `/sources/${collection.id}`,
+ graph,
+ canQueryCollections,
+ prefetched
+ )
+ );
promises.push(...sourcePromises);
}
@@ -105,10 +166,10 @@ const getPositions = (entities) => {
);
};
-export const getElements = async (api, path) => {
+export const getElements = async (api, path, canQueryCollections = false) => {
// Get a list of all Sources and Flows related to the input entity.
- const entities = await getEntities(api, path).then((graph) =>
- Object.values(graph)
+ const entities = await getEntities(api, path, {}, canQueryCollections).then(
+ (graph) => Object.values(graph)
);
const positions = getPositions(entities);
diff --git a/src/views/Embed/index.jsx b/src/views/Embed/index.jsx
index 260b5a9d..6d95fc6f 100644
--- a/src/views/Embed/index.jsx
+++ b/src/views/Embed/index.jsx
@@ -1,10 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react";
-import useSWR from "swr";
import { useApi } from "@/hooks/useApi";
import { useSources } from "@/hooks/useSources";
+import { useIngestingFlows } from "@/hooks/useFlows";
+import { useCapabilities } from "@/hooks/useService";
import useStoreManager from "@/stores/useStoreManager";
import StoreManager from "@/views/StoreManager";
import { parseTimerange } from "@/utils/timerange";
+import { isFlowGrowing } from "@/utils/flowStatus";
+import { findVideoMember } from "@/utils/editorialPurpose";
+import { TAMS_PAGE_LIMIT, VIEW_MODE } from "@/constants";
import { version as appVersion } from "../../../package.json";
import "./Embed.css";
@@ -80,37 +84,47 @@ const formatDate = (dateStr) => {
});
};
-const GROWING_STALE_MS = 10 * 60 * 1000; // 10 minutes
-
/**
- * Check if a flow is actively growing. A flow is growing if it has the
- * "ingesting" tag AND recent activity (segments_updated or created within
- * the last 10 minutes). This avoids showing abandoned ingests as "live".
+ * Resolve the video Source within a multi-Source.
*
- * A flow is definitively NOT growing if it has the "closed_complete" tag.
+ * `role === "video"` is checked first because it costs nothing, but TAMS 8.2
+ * made the collection `role` optional, so a store may not set it at all. In
+ * that case the collected Sources are fetched in one request and picked by
+ * their editorial_purpose tag, then by NMOS format.
*/
-const isFlowGrowing = (flow) => {
- if (flow.tags?.flow_status?.includes("closed_complete")) return false;
- const hasIngestingTag = flow.tags?.flow_status?.includes("ingesting") ?? false;
- if (!hasIngestingTag) return false;
- const segUpdated = flow.segments_updated;
- if (segUpdated) return Date.now() - new Date(segUpdated).getTime() < GROWING_STALE_MS;
- // No segments yet — only treat as growing if the flow was created recently
- const created = flow.created;
- if (!created) return false;
- return Date.now() - new Date(created).getTime() < GROWING_STALE_MS;
+const resolveVideoSourceId = async (source, api, canQueryCollections) => {
+ const collection = source.source_collection;
+ if (!collection?.length) return source.id;
+
+ const byRole = collection.find((member) => member?.role === "video");
+ if (byRole) return byRole.id;
+ if (!canQueryCollections) return source.id;
+
+ try {
+ const response = await api.get(
+ `/sources?collected_by_ids=${source.id}&limit=${TAMS_PAGE_LIMIT}`
+ );
+ const children = Array.isArray(response.data) ? response.data : [];
+ const byId = new Map(children.map((child) => [child.id, child]));
+ return findVideoMember(collection, byId) ?? source.id;
+ } catch {
+ return source.id;
+ }
};
/**
- * For each displayed source, fetch its video flow to get fps, duration,
- * and growing status. Growing status requires both the "ingesting" tag
- * and recent segment activity (within 10 minutes).
+ * For each displayed source, fetch its video flow to get fps, duration and
+ * growing status. Growing status comes from the flow's `status` (8.2) or the
+ * deprecated flow_status tag, guarded by recent segment activity.
*
- * On initial load, all sources are fetched once. Sources that are growing
- * are tracked by flow ID and re-polled every 5s until they close.
+ * On initial load, all sources are fetched once. Sources that are growing are
+ * tracked by flow ID and re-polled every 5s until they close. `liveSourceIds`
+ * re-opens a source that has since started ingesting, which the closed-source
+ * cache would otherwise hide until the page was reloaded.
*/
-const useSourceFlowDetails = (displayedSources) => {
+const useSourceFlowDetails = (displayedSources, liveSourceIds) => {
const api = useApi();
+ const { collectedByIds: canQueryCollections, resolved } = useCapabilities();
const [flowDetails, setFlowDetails] = useState(new Map());
const [isLoading, setIsLoading] = useState(false);
@@ -121,10 +135,14 @@ const useSourceFlowDetails = (displayedSources) => {
// Fetch all uncached sources when the source list changes
useEffect(() => {
- if (!api.endpoint || displayedSources.length === 0) return;
+ // Wait for /service: resolving a source with canQueryCollections still
+ // false would cache the wrong video flow in closedRef permanently.
+ if (!resolved || !api.endpoint || displayedSources.length === 0) return;
let cancelled = false;
const fetchAll = async () => {
+ // A source that has started ingesting since we last looked is not closed.
+ liveSourceIds.forEach((id) => closedRef.current.delete(id));
const toFetch = displayedSources.filter((s) => !closedRef.current.has(s.id));
if (toFetch.length === 0) return;
@@ -132,8 +150,11 @@ const useSourceFlowDetails = (displayedSources) => {
await Promise.all(
toFetch.map(async (source) => {
try {
- const videoSubSource = source.source_collection?.find((s) => s.role === "video");
- const videoSourceId = videoSubSource?.id || source.id;
+ const videoSourceId = await resolveVideoSourceId(
+ source,
+ api,
+ canQueryCollections
+ );
const listRes = await api.get(`/flows?source_id=${videoSourceId}&limit=1`);
const flow = listRes.data?.[0];
if (!flow) return;
@@ -174,7 +195,7 @@ const useSourceFlowDetails = (displayedSources) => {
fetchAll();
return () => { cancelled = true; };
- }, [api, displayedSources]);
+ }, [api, displayedSources, liveSourceIds, canQueryCollections, resolved]);
// Poll growing flows by their flow ID every 5s
useEffect(() => {
@@ -231,7 +252,22 @@ const Embed = () => {
const needsConfig = !activeStore || !cuttingRoomTamsId;
const [showConfig, setShowConfig] = useState(needsConfig);
const [importing, setImporting] = useState(false);
- const { sources, isLoading: sourcesLoading, error: sourcesError } = useSources();
+ const { sources, isLoading: sourcesLoading, error: sourcesError } = useSources({
+ viewMode: VIEW_MODE.TOP_LEVEL,
+ sortBy: "created",
+ });
+
+ // Flows the store reports as ingesting, so a recording that starts while the
+ // page is open lights up without re-scanning every source.
+ const { flows: ingestingFlows } = useIngestingFlows();
+ const liveSourceKey = (ingestingFlows ?? [])
+ .map((flow) => flow.source_id)
+ .sort()
+ .join(",");
+ const liveSourceIds = useMemo(
+ () => new Set(liveSourceKey ? liveSourceKey.split(",") : []),
+ [liveSourceKey]
+ );
// Auto-sync from main app's localStorage if we already have storage access
useEffect(() => {
@@ -255,26 +291,22 @@ const Embed = () => {
setImporting(false);
};
- // Filter to displayable sources (not collected by a multi-source)
+ // useSources already scopes this to top-level sources, newest first where the
+ // store can sort. Older stores return them unordered, so sort here too.
const displayedSources = useMemo(() => {
if (!sources) return [];
- const collectedIds = new Set();
- for (const source of sources) {
- if (source.collected_by?.length) {
- collectedIds.add(source.id);
- }
- }
- return sources
- .filter((source) => !collectedIds.has(source.id))
- .sort((a, b) => {
- const da = a.created ? new Date(a.created) : new Date(0);
- const db = b.created ? new Date(b.created) : new Date(0);
- return db - da;
- });
+ return [...sources].sort((a, b) => {
+ const da = a.created ? new Date(a.created) : new Date(0);
+ const db = b.created ? new Date(b.created) : new Date(0);
+ return db - da;
+ });
}, [sources]);
// Fetch flow details (fps, duration, growing) for each displayed source
- const { flowDetails, isLoading: detailsLoading } = useSourceFlowDetails(displayedSources);
+ const { flowDetails, isLoading: detailsLoading } = useSourceFlowDetails(
+ displayedSources,
+ liveSourceIds
+ );
const enrichedSources = useMemo(() => {
return displayedSources.map((source) => {
diff --git a/src/views/Flow/components/ProfileTab.jsx b/src/views/Flow/components/ProfileTab.jsx
new file mode 100644
index 00000000..8523bc31
--- /dev/null
+++ b/src/views/Flow/components/ProfileTab.jsx
@@ -0,0 +1,44 @@
+import { Box, SpaceBetween, Spinner } from "@cloudscape-design/components";
+import { Link } from "react-router-dom";
+import EssenceParameters from "./EssenceParameters";
+import { useProfile } from "@/hooks/useService";
+
+/**
+ * The Flow carries only `profile_id`, so the Profile itself is fetched to show
+ * the technical metadata it applied to this Flow.
+ */
+const ProfileTab = ({ profileId }) => {
+ const { profile, supported, resolved, detectionFailed, isLoading, error } =
+ useProfile(profileId);
+
+ if (!resolved || isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {profile?.label || profileId}
+
+ {error ? (
+
+ Could not load profile: {error.message}
+
+ ) : detectionFailed || !supported ? (
+
+ {detectionFailed
+ ? "Profile metadata unavailable: could not read the store's capabilities."
+ : "This store does not advertise Flow Profiles, so its metadata cannot be shown."}
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default ProfileTab;
diff --git a/src/views/Flow/components/SegmentsTab.jsx b/src/views/Flow/components/SegmentsTab.jsx
index 26a5b4d4..9804bce4 100644
--- a/src/views/Flow/components/SegmentsTab.jsx
+++ b/src/views/Flow/components/SegmentsTab.jsx
@@ -1,6 +1,10 @@
+import { useState } from "react";
import {
+ Badge,
Box,
CollectionPreferences,
+ FormField,
+ Select,
SpaceBetween,
Table,
} from "@cloudscape-design/components";
@@ -8,12 +12,35 @@ import usePreferencesStore from "@/stores/usePreferencesStore";
import { SEGMENT_COUNT, DATE_FORMAT } from "@/constants";
import { parseTimerangeDateTime } from "@/utils/timerange";
+import { isInitSegmentEntry } from "@/utils/initSegments";
import { useLastN } from "@/hooks/useSegments";
+import { useStorageBackends } from "@/hooks/useService";
+
+const ANY = "__any__";
+const ANY_OPTION = { value: ANY, label: "Any" };
const SegmentsTab = ({ flowId }) => {
const preferences = usePreferencesStore((state) => state.segmentsPreferences);
const setPreferences = usePreferencesStore((state) => state.setSegmentsPreferences);
- const { segments, isLoading: loadingSegments } = useLastN(flowId, SEGMENT_COUNT);
+
+ // 8.2 storage_backend_tag filters narrow which storage backends' get_urls are
+ // returned. The options come from the tags the store's backends actually carry.
+ const {
+ tags: storageTags,
+ supported: supportsStorageTags,
+ error: storageTagsError,
+ } = useStorageBackends();
+ const [tagName, setTagName] = useState(ANY);
+ const [tagValue, setTagValue] = useState(ANY);
+
+ const selectedTag = storageTags.find((tag) => tag.name === tagName);
+ const { segments, isLoading: loadingSegments } = useLastN(
+ flowId,
+ SEGMENT_COUNT,
+ tagName === ANY
+ ? undefined
+ : { name: tagName, value: tagValue === ANY ? undefined : tagValue }
+ );
const columnDefinitions = [
{
@@ -42,6 +69,15 @@ const SegmentsTab = ({ flowId }) => {
header: "Object Timerange",
cell: (item) => item.object_timerange,
},
+ {
+ id: "init_object",
+ header: "Init Object",
+ // Pre-8.2 stores have no init_object; they list the init object itself as
+ // a zero-length entry, which is easily mistaken for a media segment.
+ cell: (item) =>
+ item.init_object?.object_id ??
+ (isInitSegmentEntry(item) ? init segment : null),
+ },
{
id: "sample_offset",
header: "Sample Offset",
@@ -83,10 +119,54 @@ const SegmentsTab = ({ flowId }) => {
title: "Preferences",
};
-
return (
Showing last {SEGMENT_COUNT} segments
+ {supportsStorageTags && storageTagsError && (
+
+ Storage backend tags unavailable: {storageTagsError.message}
+
+ )}
+ {supportsStorageTags && storageTags.length > 0 && (
+
+
+
+
+
+
+ )}
{
),
},
@@ -75,6 +77,15 @@ const Flow = () => {
/>
),
},
+ ...(flow.profile_id
+ ? [
+ {
+ label: "Profile",
+ id: "profile",
+ content: ,
+ },
+ ]
+ : []),
{
label: "Segments",
id: "segments",
diff --git a/src/views/Flows/index.jsx b/src/views/Flows/index.jsx
index e9cacd8f..da13eece 100644
--- a/src/views/Flows/index.jsx
+++ b/src/views/Flows/index.jsx
@@ -1,12 +1,15 @@
-import { PAGE_SIZE_PREFERENCE } from "@/constants";
+import { useMemo, useState } from "react";
import {
Alert,
Box,
+ Button,
CollectionPreferences,
CopyToClipboard,
Header,
Pagination,
+ Select,
SpaceBetween,
+ StatusIndicator,
Table,
TextFilter,
Toggle,
@@ -14,8 +17,44 @@ import {
import { useFlows } from "@/hooks/useFlows";
import { Link } from "react-router-dom";
import { useCollection } from "@cloudscape-design/collection-hooks";
+import { SERVER_SORT_FIELDS, toReverseOrder } from "@/hooks/useEntityListing";
import usePreferencesStore from "@/stores/usePreferencesStore";
import FlowActionsButton from "@/components/FlowActionsButton";
+import { getEditorialPurpose } from "@/utils/editorialPurpose";
+import { hasInitSegments } from "@/utils/initSegments";
+import { MAX_DEPTH } from "@/utils/fetchEntityTree";
+import { FLOW_STATUS_VALUES, getFlowStatus } from "@/utils/flowStatus";
+import {
+ FLOW_STATUS_MAPPINGS,
+ PAGE_SIZE_PREFERENCE,
+ RESULT_PAGE_SIZE,
+ VIEW_MODE,
+} from "@/constants";
+
+const VIEW_MODE_OPTIONS = [
+ {
+ value: VIEW_MODE.ALL,
+ label: "All flows",
+ description: "Every flow in the store",
+ },
+ {
+ value: VIEW_MODE.TOP_LEVEL,
+ label: "Top-level only",
+ description: "Flows that are not collected by another flow",
+ },
+ {
+ value: VIEW_MODE.MULTI_ONLY,
+ label: "Multi-flows only",
+ description: "Top-level flows that collect other flows",
+ },
+];
+
+const ANY_STATUS = "__any__";
+
+const STATUS_OPTIONS = [
+ { value: ANY_STATUS, label: "Any status" },
+ ...FLOW_STATUS_VALUES.map((value) => ({ value, label: value })),
+];
const columnDefinitions = [
{
@@ -73,6 +112,50 @@ const columnDefinitions = [
cell: (item) => item.created,
sortingField: "created",
},
+ {
+ id: "status",
+ header: "Status",
+ // 8.2 core attribute, falling back to the deprecated flow_status tag.
+ cell: (item) => {
+ const status = getFlowStatus(item);
+ return status ? (
+
+ {status}
+
+ ) : null;
+ },
+ sortingComparator: (a, b) =>
+ (getFlowStatus(a) ?? "").localeCompare(getFlowStatus(b) ?? ""),
+ },
+ {
+ id: "editorial_purpose",
+ header: "Editorial purpose",
+ cell: (item) => getEditorialPurpose(item),
+ sortingComparator: (a, b) =>
+ (getEditorialPurpose(a) ?? "").localeCompare(getEditorialPurpose(b) ?? ""),
+ },
+ {
+ id: "init_segments",
+ header: "Init segments",
+ // 8.2 puts this under essence_parameters, not on the Flow; pre-8.2 stores
+ // use the tags.init_segment Flow tag instead.
+ cell: (item) => {
+ if (hasInitSegments(item)) return "true";
+ // Blank means the store did not say; only an explicit false reads false.
+ return item.essence_parameters?.init_segments == null ? null : "false";
+ },
+ sortingComparator: (a, b) =>
+ Number(hasInitSegments(a)) - Number(hasInitSegments(b)),
+ },
+ {
+ id: "profile_id",
+ header: "Profile",
+ cell: (item) =>
+ item.profile_id ? (
+ {item.profile_id}
+ ) : null,
+ sortingField: "profile_id",
+ },
{
id: "tags",
header: "Tags",
@@ -162,6 +245,11 @@ const collectionPreferencesProps = {
title: "Preferences",
};
+const defaultSorting = {
+ sortingColumn: columnDefinitions.find((col) => col.id === "created"),
+ isDescending: true,
+};
+
const Flows = () => {
const preferences = usePreferencesStore((state) => state.flowsPreferences);
const setPreferences = usePreferencesStore(
@@ -173,13 +261,60 @@ const Flows = () => {
const setShowHierarchy = usePreferencesStore(
(state) => state.setFlowsShowHierarchy
);
- const { flows, isLoading, error } = useFlows();
+ const viewMode = usePreferencesStore((state) => state.flowsViewMode);
+ const setViewMode = usePreferencesStore((state) => state.setFlowsViewMode);
+
+ const [sorting, setSorting] = useState(defaultSorting);
+ const [maxResults, setMaxResults] = useState(RESULT_PAGE_SIZE);
+ const [statusFilter, setStatusFilter] = useState(ANY_STATUS);
+
+ const sortField = sorting.sortingColumn?.sortingField;
+ const sortBy = SERVER_SORT_FIELDS.flows.includes(sortField)
+ ? sortField
+ : undefined;
+
+ const hierarchical = showHierarchy && viewMode === VIEW_MODE.ALL;
+
+ const {
+ flows,
+ hasMore,
+ truncated,
+ loadedCount,
+ capabilities,
+ treeMode,
+ isLoading,
+ error,
+ } = useFlows({
+ viewMode,
+ hierarchical,
+ sortBy,
+ reverseOrder: sortBy
+ ? toReverseOrder(sortBy, sorting.isDescending)
+ : false,
+ status: statusFilter === ANY_STATUS ? undefined : statusFilter,
+ maxResults,
+ });
+
+ // Filter locally unless the store did it: older stores have no status query
+ // parameter, and hierarchical mode fetches by structure without sending one.
+ const serverFiltered = capabilities.flowStatus && !treeMode;
+ const visibleFlows = useMemo(() => {
+ if (!flows) return flows;
+ if (statusFilter === ANY_STATUS || serverFiltered) return flows;
+ return flows.filter((flow) => getFlowStatus(flow) === statusFilter);
+ }, [flows, statusFilter, serverFiltered]);
+
+ const loadedIds = useMemo(
+ () => new Set((visibleFlows ?? []).map((flow) => flow.id)),
+ [visibleFlows]
+ );
+
const { items, collectionProps, filterProps, paginationProps } =
- useCollection(isLoading || error ? [] : flows ?? [], {
- expandableRows: showHierarchy && {
+ useCollection(isLoading || error ? [] : visibleFlows ?? [], {
+ expandableRows: hierarchical && {
getId: (item) => item.id,
getParentId: (item) =>
- item.collected_by ? item.collected_by[0] : null,
+ item.collected_by?.find((id) => loadedIds.has(id)) ?? null,
},
filtering: {
empty: (
@@ -194,16 +329,37 @@ const Flows = () => {
),
},
pagination: { pageSize: preferences.pageSize },
- sorting: {
- defaultState: {
- sortingColumn: columnDefinitions.find((col) => col.id === "created"),
- isDescending: true,
- },
- },
+ sorting: { defaultState: defaultSorting },
selection: {},
});
const { selectedItems } = collectionProps;
+ let description;
+ if (capabilities.detectionFailed) {
+ description = `Could not read the store's api_version (${capabilities.error.message}); falling back to TAMS 8.0 behaviour.`;
+ } else if (!capabilities.flowStatus) {
+ description = `Store reports TAMS ${
+ capabilities.apiVersion ?? "8.0 or earlier"
+ }; status is read from the deprecated flow_status tag.`;
+ } else if (treeMode) {
+ description =
+ "Status comes from the flow's status attribute. Hierarchical view fetches the tree by structure, so filtering and sorting apply to the loaded rows.";
+ } else {
+ description =
+ "Status comes from the flow's status attribute; the store applies the status filter and the Created, Metadata updated and Label sorts.";
+ }
+
+ // Filtering a tree drops non-matching parents, and Cloudscape promotes their
+ // children to the top level — so say so rather than showing a tree that
+ // quietly differs from the real hierarchy.
+ if (treeMode && statusFilter !== ANY_STATUS) {
+ description = `${description} Flows whose parent does not match the filter are shown at the top level.`;
+ }
+
+ if (treeMode && truncated) {
+ description = `${description} The hierarchy may be incomplete: it is nested at least ${MAX_DEPTH} levels deep, or a collection has more children than one page.`;
+ }
+
if (error) {
return (
@@ -219,15 +375,48 @@ const Flows = () => {
+ {hasMore && (
+
+ )}
+