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 && ( + + + setTagValue(detail.selectedOption.value)} + options={[ + ANY_OPTION, + ...(selectedTag?.values ?? []).map((value) => ({ + value, + label: value, + })), + ]} + /> + + + )}
{ ), }, @@ -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 && ( + + )} + option.value === viewMode) ?? + VIEW_MODE_OPTIONS[0] + } + onChange={({ detail }) => + setViewMode(detail.selectedOption.value) + } + options={VIEW_MODE_OPTIONS} + /> setShowHierarchy(detail.checked)} checked={showHierarchy} + disabled={viewMode !== VIEW_MODE.ALL} > Hierarchical View @@ -239,6 +428,10 @@ const Flows = () => { } {...collectionProps} + onSortingChange={(event) => { + setSorting(event.detail); + collectionProps.onSortingChange?.(event); + }} variant="borderless" loadingText="Loading resources" loading={isLoading} diff --git a/src/views/Home/index.jsx b/src/views/Home/index.jsx index c71f42d5..7c1ccf87 100644 --- a/src/views/Home/index.jsx +++ b/src/views/Home/index.jsx @@ -23,10 +23,20 @@ This project is a fork of [AWS TAMS Tools](https://github.com/aws-samples/time-a TAMS itself is a [BBC initiative](https://github.com/bbc/tams) for time-addressable media. +### TAMS versions + +This tool targets [TAMS 8.2](https://bbc.github.io/tams/8.2/index.html) and works against 8.0 and 8.1 stores too. It reads \`api_version\` from the store's \`/service\` endpoint and only uses newer query parameters where the store advertises support -- the detected version is shown next to **Manage Stores** in the sidebar. On an 8.2 store you additionally get: + +- Server-side sorting, so the newest sources arrive in the first request instead of after downloading the whole store +- Scoped listings -- **Top-level only** and **Multi-sources only** -- built on the \`collected_by_ids\` filter +- Ingest status from the flow's \`status\` attribute rather than the deprecated \`flow_status\` tag +- Flow Profiles, init segments, and storage-backend tag filters + ### Features -- **Sources** -- Browse all sources in the active TAMS store, with filtering, sorting, and column customisation. -- **Flows** -- Browse flows, manage timeranges, and view detailed metadata. +- **Sources** -- Browse all sources in the active TAMS store, with filtering, sorting, scoped views, and column customisation. +- **Flows** -- Browse flows, filter by ingest status, manage timeranges, and view detailed metadata. +- **Profiles** -- Browse Flow Profiles and the technical metadata they apply (TAMS 8.2 stores). - **Webhooks** -- List, register, update, and delete TAMS webhooks; inspect delivery errors. - **Omakase Player** -- Advanced video player with timeline visualisation and markers. - **Diagram View** -- Interactive graph of TAMS entity relationships (sources, flows, segments). @@ -35,7 +45,7 @@ TAMS itself is a [BBC initiative](https://github.com/bbc/tams) for time-addressa 1. Click **Manage Stores** (or use the sidebar) 2. Enter a name and the base URL of your TAMS API endpoint -3. Optionally add authentication (Bearer token or OAuth2 client credentials) +3. Add authentication -- OAuth2 client credentials by default, or a Bearer token, or none for public endpoints 4. Click **Add**, then browse Sources and Flows ### Privacy diff --git a/src/views/Layout.jsx b/src/views/Layout.jsx index 1a0b107f..16d5530d 100644 --- a/src/views/Layout.jsx +++ b/src/views/Layout.jsx @@ -1,5 +1,6 @@ import { AppLayout, + Badge, BreadcrumbGroup, ContentLayout, Flashbar, @@ -11,6 +12,7 @@ import Header from "@/components/Header"; import { useState } from "react"; import useAlertsStore from "@/stores/useAlertsStore"; import useStoreManager from "@/stores/useStoreManager"; +import { useCapabilities } from "@/hooks/useService"; const Layout = () => { const [navigationOpen, setNavigationOpen] = useState(true); @@ -56,6 +58,12 @@ const Layout = () => { }; const activeStore = useStoreManager((s) => s.getActiveStore()); + const { + apiVersion, + profiles: supportsProfiles, + resolved, + detectionFailed, + } = useCapabilities(); const navHeader = { text: activeStore ? activeStore.name : "No store selected", @@ -65,6 +73,14 @@ const Layout = () => { const navItems = [ { type: "link", text: "Sources", href: "/sources", disabled: !activeStore }, { type: "link", text: "Flows", href: "/flows", disabled: !activeStore }, + { + type: "link", + text: "Profiles", + href: "/profiles", + // 8.2 only; shown but disabled elsewhere so the gap is visible rather than + // the menu item silently disappearing. + disabled: !activeStore || !supportsProfiles, + }, { type: "link", text: "Webhooks", @@ -72,7 +88,18 @@ const Layout = () => { disabled: !activeStore, }, { type: "divider" }, - { type: "link", text: "Manage Stores", href: "/stores" }, + { + type: "link", + text: "Manage Stores", + href: "/stores", + // Red distinguishes "could not reach /service" from a store that simply + // advertises no version. + info: activeStore && resolved && ( + + {apiVersion ? `TAMS ${apiVersion}` : "TAMS ?"} + + ), + }, ]; return ( diff --git a/src/views/Profile/index.jsx b/src/views/Profile/index.jsx new file mode 100644 index 00000000..c2f28ed8 --- /dev/null +++ b/src/views/Profile/index.jsx @@ -0,0 +1,85 @@ +import { + Alert, + Box, + Header, + SpaceBetween, + Spinner, + Tabs, +} from "@cloudscape-design/components"; +import { useParams } from "react-router-dom"; + +import EntityDetails from "@/components/EntityDetails"; +import EssenceParameters from "@/views/Flow/components/EssenceParameters"; +import { useProfile } from "@/hooks/useService"; + +const Profile = () => { + const { profileId } = useParams(); + const { profile, supported, resolved, detectionFailed, isLoading, error } = + useProfile(profileId); + + // Until /service answers we do not know whether the store has Profiles, so + // wait rather than claiming it does not. + if (!resolved) { + return ( + + + + ); + } + + if (detectionFailed) { + return ( + + Reading /service failed, so Flow Profile support is unknown. + + ); + } + + if (!supported) { + return ( + + Flow Profiles were introduced in TAMS 8.2. The active store does not + advertise support for them. + + ); + } + + if (error) { + return ( + + Failed to load profile from the active store. + + {error.message} + + + ); + } + + if (isLoading) { + return ( + + + + ); + } + + if (!profile) return `No profile found with the id ${profileId}`; + + return ( + +
{profile.label || profile.id}
+ + , + }, + ]} + /> +
+ ); +}; + +export default Profile; diff --git a/src/views/Profiles/index.jsx b/src/views/Profiles/index.jsx new file mode 100644 index 00000000..e635d801 --- /dev/null +++ b/src/views/Profiles/index.jsx @@ -0,0 +1,195 @@ +import { + Alert, + Box, + CollectionPreferences, + CopyToClipboard, + Header, + Pagination, + Spinner, + Table, + TextFilter, +} from "@cloudscape-design/components"; +import { Link } from "react-router-dom"; +import { useCollection } from "@cloudscape-design/collection-hooks"; +import { useProfiles } from "@/hooks/useService"; +import usePreferencesStore from "@/stores/usePreferencesStore"; +import { PAGE_SIZE_PREFERENCE } from "@/constants"; + +const columnDefinitions = [ + { + id: "id", + header: "Id", + cell: (item) => ( + <> + {item.id} + + + ), + sortingField: "id", + isRowHeader: true, + width: 360, + }, + { + id: "label", + header: "Label", + cell: (item) => item.label, + sortingField: "label", + }, + { + id: "description", + header: "Description", + cell: (item) => item.description, + sortingField: "description", + }, + { + id: "format", + header: "Format", + cell: (item) => item.flow_metadata?.format, + }, + { + id: "codec", + header: "Codec", + cell: (item) => item.flow_metadata?.codec, + }, + { + id: "container", + header: "Container", + cell: (item) => item.flow_metadata?.container, + }, + { + id: "created_by", + header: "Created by", + cell: (item) => item.created_by, + sortingField: "created_by", + }, + { + id: "created", + header: "Created", + cell: (item) => item.created, + sortingField: "created", + }, +]; + +const collectionPreferencesProps = { + pageSizePreference: PAGE_SIZE_PREFERENCE, + contentDisplayPreference: { + title: "Column preferences", + description: "Customize the columns visibility and order.", + options: columnDefinitions.map(({ id, header }) => ({ + id, + label: header, + alwaysVisible: id === "id", + })), + }, + cancelLabel: "Cancel", + confirmLabel: "Confirm", + title: "Preferences", +}; + +const Profiles = () => { + const preferences = usePreferencesStore((state) => state.profilesPreferences); + const setPreferences = usePreferencesStore( + (state) => state.setProfilesPreferences + ); + const { profiles, supported, resolved, detectionFailed, isLoading, error } = + useProfiles(); + + const { items, collectionProps, filterProps, paginationProps } = useCollection( + isLoading || error ? [] : profiles ?? [], + { + filtering: { + empty: ( + + No profiles + + ), + noMatch: ( + + No matches + + ), + }, + pagination: { pageSize: preferences.pageSize }, + sorting: { + defaultState: { + sortingColumn: columnDefinitions.find((col) => col.id === "created"), + isDescending: true, + }, + }, + } + ); + + // Until /service answers we do not know whether the store has Profiles, so + // wait rather than claiming it does not. + if (!resolved) { + return ( + + + + ); + } + + if (detectionFailed) { + return ( + + Reading /service failed, so Flow Profile support is unknown. + + ); + } + + if (!supported) { + return ( + + Flow Profiles were introduced in TAMS 8.2. The active store does not + advertise support for them at /service/profiles. + + ); + } + + if (error) { + return ( + + Failed to load profiles from the active store. + + {error.message} + + + ); + } + + return ( +
+ Profiles + + } + {...collectionProps} + variant="borderless" + loadingText="Loading resources" + loading={isLoading} + trackBy="id" + columnDefinitions={columnDefinitions} + columnDisplay={preferences.contentDisplay} + contentDensity="compact" + items={items} + pagination={} + filter={} + preferences={ + setPreferences(detail)} + /> + } + /> + ); +}; + +export default Profiles; diff --git a/src/views/Source/index.jsx b/src/views/Source/index.jsx index 1678635c..25ecfae7 100644 --- a/src/views/Source/index.jsx +++ b/src/views/Source/index.jsx @@ -52,6 +52,7 @@ const Source = () => { ), }, diff --git a/src/views/Sources/index.jsx b/src/views/Sources/index.jsx index 7b503f24..6c1f6bca 100644 --- a/src/views/Sources/index.jsx +++ b/src/views/Sources/index.jsx @@ -1,10 +1,13 @@ +import { useMemo, useState } from "react"; import { Alert, Box, + Button, CollectionPreferences, CopyToClipboard, Header, Pagination, + Select, SpaceBetween, Table, TextFilter, @@ -14,8 +17,33 @@ import { import { Link } from "react-router-dom"; import { useCollection } from "@cloudscape-design/collection-hooks"; import { useSources } from "@/hooks/useSources"; +import { SERVER_SORT_FIELDS, toReverseOrder } from "@/hooks/useEntityListing"; import usePreferencesStore from "@/stores/usePreferencesStore"; -import { PAGE_SIZE_PREFERENCE } from "@/constants"; +import { getEditorialPurpose } from "@/utils/editorialPurpose"; +import { MAX_DEPTH } from "@/utils/fetchEntityTree"; +import { + PAGE_SIZE_PREFERENCE, + RESULT_PAGE_SIZE, + VIEW_MODE, +} from "@/constants"; + +const VIEW_MODE_OPTIONS = [ + { + value: VIEW_MODE.ALL, + label: "All sources", + description: "Every source in the store", + }, + { + value: VIEW_MODE.TOP_LEVEL, + label: "Top-level only", + description: "Sources that are not collected by another source", + }, + { + value: VIEW_MODE.MULTI_ONLY, + label: "Multi-sources only", + description: "Top-level sources that collect other sources", + }, +]; const columnDefinitions = [ { @@ -79,6 +107,14 @@ const columnDefinitions = [ cell: (item) => item.updated, sortingField: "updated", }, + { + id: "editorial_purpose", + header: "Editorial purpose", + // Lives under tags, so there is no top-level field to sort on. + cell: (item) => getEditorialPurpose(item), + sortingComparator: (a, b) => + (getEditorialPurpose(a) ?? "").localeCompare(getEditorialPurpose(b) ?? ""), + }, { id: "tags", header: "Tags", @@ -114,6 +150,11 @@ const collectionPreferencesProps = { title: "Preferences", }; +const defaultSorting = { + sortingColumn: columnDefinitions.find((col) => col.id === "created"), + isDescending: true, +}; + const Sources = () => { const preferences = usePreferencesStore((state) => state.sourcesPreferences); const setPreferences = usePreferencesStore( @@ -125,13 +166,52 @@ const Sources = () => { const setShowHierarchy = usePreferencesStore( (state) => state.setSourcesShowHierarchy ); - const { sources, isLoading, error } = useSources(); + const viewMode = usePreferencesStore((state) => state.sourcesViewMode); + const setViewMode = usePreferencesStore((state) => state.setSourcesViewMode); + + // Mirrors the table's sorting state so it can be pushed into the query. The + // table stays the single sorting control; this just reads what it is doing. + const [sorting, setSorting] = useState(defaultSorting); + const [maxResults, setMaxResults] = useState(RESULT_PAGE_SIZE); + + const sortField = sorting.sortingColumn?.sortingField; + const sortBy = SERVER_SORT_FIELDS.sources.includes(sortField) + ? sortField + : undefined; + + // The hierarchy needs children in the item set, which the scoped views exclude. + const hierarchical = showHierarchy && viewMode === VIEW_MODE.ALL; + + const { + sources, + hasMore, + truncated, + loadedCount, + capabilities, + treeMode, + isLoading, + error, + } = useSources({ + viewMode, + hierarchical, + sortBy, + reverseOrder: sortBy ? toReverseOrder(sortBy, sorting.isDescending) : false, + maxResults, + }); + + const loadedIds = useMemo( + () => new Set((sources ?? []).map((source) => source.id)), + [sources] + ); + const { items, collectionProps, filterProps, paginationProps } = useCollection(isLoading || error ? [] : sources ?? [], { - expandableRows: showHierarchy && { + expandableRows: hierarchical && { getId: (item) => item.id, + // Prefer a parent that is actually loaded: a source may be collected by + // several sources, and only some of them may be in view. getParentId: (item) => - item.collected_by ? item.collected_by[0] : null, + item.collected_by?.find((id) => loadedIds.has(id)) ?? null, }, filtering: { empty: ( @@ -146,15 +226,29 @@ const Sources = () => { ), }, pagination: { pageSize: preferences.pageSize }, - sorting: { - defaultState: { - sortingColumn: columnDefinitions.find((col) => col.id === "created"), - isDescending: true, - }, - }, + sorting: { defaultState: defaultSorting }, selection: {}, }); + 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.sortBy) { + description = `Store reports TAMS ${ + capabilities.apiVersion ?? "8.0 or earlier" + }; all sources are loaded and sorted in the browser.`; + } else if (treeMode) { + description = + "Hierarchical view fetches the tree by structure, so all columns sort the loaded rows."; + } else { + description = + "Sorted by the store. Sort on Created, Updated or Label to re-query; other columns sort the loaded rows."; + } + + 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 ( @@ -170,15 +264,38 @@ const Sources = () => {
+ {hasMore && ( + + )} + setFormToken(detail.value)} - placeholder="Token" - type="password" - /> - - - ), - }, { id: "client_credentials", label: "Client / Secret", @@ -369,6 +364,31 @@ const StoreManager = () => { ), }, + { + id: "bearer", + label: "Bearer Token", + content: ( + + + setFormToken(detail.value)} + placeholder="Token" + type="password" + /> + + + ), + }, + { + id: "none", + label: "None", + content: ( + + No authentication. Use for public TAMS endpoints. + + ), + }, ]} /> diff --git a/src/views/Webhooks/index.jsx b/src/views/Webhooks/index.jsx index 41d52e88..8f9ec9cd 100644 --- a/src/views/Webhooks/index.jsx +++ b/src/views/Webhooks/index.jsx @@ -18,7 +18,7 @@ import { useWebhooks } from "@/hooks/useWebhooks"; import usePreferencesStore from "@/stores/usePreferencesStore"; import WebhookRegisterUpdateModal from "@/components/WebhookRegisterUpdateModal"; import WebhookActionsButton from "@/components/WebhookActionsButton"; -import { PAGE_SIZE_PREFERENCE, STATUS_MAPPINGS } from "@/constants"; +import { PAGE_SIZE_PREFERENCE, WEBHOOK_STATUS_MAPPINGS } from "@/constants"; const columnDefinitions = [ { @@ -46,7 +46,7 @@ const columnDefinitions = [ header: "Status", cell: (item) => item.status ? ( - + {item.status} ) : null, @@ -156,6 +156,17 @@ const columnDefinitions = [ sortingField: "accept_storage_ids", width: 360, }, + { + id: "include_object_timerange", + header: "Include object timerange", + cell: (item) => + item.include_object_timerange !== undefined && ( + + ), + sortingField: "include_object_timerange", + }, { id: "presigned", header: "Presigned",