From e573c6c58e22db6a87e4328585b07d66a8b4a50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 20:36:53 +0200 Subject: [PATCH 01/16] VI-5068 Support TAMS 8.2 in the TAMS Store Browser Adds support for the TAMS 8.2 specification while keeping 8.0 and 8.1 stores working exactly as before. Every 8.2 feature is gated on the api_version the store advertises at /service, and anything unreadable is treated as the 8.0 baseline rather than an error. The three additions that remove workarounds we were carrying: - sort_by / reverse_order let the store order listings, so the newest sources arrive in the first request instead of after walking every page. Capable stores stop after a page and offer to load more; older stores keep the previous fetch-everything behaviour, since their first page has no guaranteed order. The table's own column sorting drives the query, so there is still a single sorting control. - collected_by_ids= (empty) scopes listings to top-level entities. Both listings gain an All / Top-level only / Multi-sources only selector, and the Diagram's per-child fan-out collapses into one batched request per collection. - The flow_status tag is now the core Flow status attribute, with a status column and filter. The staleness guard is kept as a secondary check because the spec is explicit that status is client-maintained and a crashed ingest can leave it stale, but it now degrades honestly: with no activity timestamp available the reported status is trusted. Also fixes a latent break: 8.2 makes the collection role optional, and the Embed view relied on role === "video" to find a source's video, so it would have silently lost fps, duration and live status against a store that omits it. Video resolution now falls back to the editorial_purpose tag and then to the NMOS format. Collections render in server order, as 8.2 requires, and show role only where a store sets it. Remaining 8.2 surface: a Flow Profiles browser, init segments (which live under essence_parameters, not on the Flow), storage-backend tag filters on segments, and include_object_timerange on webhooks. Unrelated to 8.2, client credentials becomes the default authentication for a new store, as that is what most TAMS deployments use. Editing an existing store still respects whatever it was configured with. Two notes for reviewers: - Cloudscape cannot lazy-load an expandable tree: a row only becomes expandable once its children are in the item set, and expandedItems is not a controlled prop. Hierarchy mode therefore descends level by level via collected_by_ids rather than fetching on expand. That is still a few requests instead of one per collection, and unlike the previous client-side tree it is correct across page boundaries and handles an entity collected by more than one parent. - npm run lint does not run in this repo and did not before this change: ESLint 9 needs a flat config and only .eslintrc.cjs exists, and eslint-plugin-react-hooks@7 fails to load against the hoisted zod@3.23.8. Verified instead with the production build, an import cycle scan, executable assertions over the new pure logic, and a temporary flat config confirming no new lint errors. Fixing the lint toolchain needs a player dependency bump and belongs in its own ticket. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 29 ++- src/App.jsx | 6 + src/components/Collection.jsx | 94 +++++-- src/components/EntityDetails.jsx | 24 +- src/components/WebhookRegisterUpdateModal.jsx | 15 ++ src/constants.js | 28 +- src/hooks/useEntityListing.jsx | 107 ++++++++ src/hooks/useFlows.jsx | 78 ++++-- src/hooks/useSegments.jsx | 57 ++-- src/hooks/useService.jsx | 117 +++++++++ src/hooks/useSources.jsx | 68 +++-- src/hooks/useWebhooks.jsx | 2 +- src/stores/usePreferencesStore.jsx | 244 ++++++++++++------ src/utils/buildQuery.js | 16 ++ src/utils/capabilities.js | 37 +++ src/utils/editorialPurpose.js | 58 +++++ src/utils/fetchEntityTree.js | 66 +++++ src/utils/flowStatus.js | 58 +++++ src/utils/paginationFetcher.js | 18 +- src/views/Diagram/index.jsx | 10 +- src/views/Diagram/utils.js | 85 +++++- src/views/Embed/index.jsx | 113 +++++--- src/views/Flow/components/ProfileTab.jsx | 37 +++ src/views/Flow/components/SegmentsTab.jsx | 68 ++++- src/views/Flow/index.jsx | 11 + src/views/Flows/index.jsx | 182 ++++++++++++- src/views/Home/index.jsx | 16 +- src/views/Layout.jsx | 22 +- src/views/Profile/index.jsx | 66 +++++ src/views/Profiles/index.jsx | 175 +++++++++++++ src/views/Source/index.jsx | 1 + src/views/Sources/index.jsx | 119 ++++++++- src/views/StoreManager/index.jsx | 77 +++--- src/views/Webhooks/index.jsx | 15 +- 34 files changed, 1832 insertions(+), 287 deletions(-) create mode 100644 src/hooks/useEntityListing.jsx create mode 100644 src/hooks/useService.jsx create mode 100644 src/utils/buildQuery.js create mode 100644 src/utils/capabilities.js create mode 100644 src/utils/editorialPurpose.js create mode 100644 src/utils/fetchEntityTree.js create mode 100644 src/utils/flowStatus.js create mode 100644 src/views/Flow/components/ProfileTab.jsx create mode 100644 src/views/Profile/index.jsx create mode 100644 src/views/Profiles/index.jsx diff --git a/README.md b/README.md index e7229525..f845e2b7 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,42 @@ 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 only the multi-sources 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 are fully supported. + +The tool reads `api_version` from the store's `/service` endpoint and only sends +newer query parameters to stores that advertise support for them, 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 | +| `init_segments` | Flow attribute display and filter | +| `storage_backend_tag.{name}` | Narrowing segment `get_urls` by storage backend | +| `include_object_timerange` | Webhook registration option | + ## 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/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..097befa9 100644 --- a/src/components/Collection.jsx +++ b/src/components/Collection.jsx @@ -1,42 +1,98 @@ +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); -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, + }, + ] + : []), + ]; + + 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. + */ +export const useEntityListing = (entityType, options = {}) => { + const { + viewMode = VIEW_MODE.ALL, + hierarchical = false, + sortBy, + reverseOrder = false, + status, + profileId, + initSegments, + 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, + profile_id: capabilities.profiles ? profileId : undefined, + init_segments: capabilities.initSegments ? initSegments : 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, + loadedCount: data?.items.length ?? 0, + capabilities, + 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..a5db83d9 --- /dev/null +++ b/src/hooks/useService.jsx @@ -0,0 +1,117 @@ +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, + shouldRetryOnError: false, + } + ); + + 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. + */ +export const useCapabilities = () => { + const { service, isLoading, error } = useService(); + const capabilities = getCapabilities(service?.api_version); + + return { + ...capabilities, + service, + error, + resolved: !isLoading, + }; +}; + +/** + * 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 } = useCapabilities(); + + const { data, error, isLoading } = useSWR( + api.endpoint ? [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: supported && resolved, + isLoading, + error, + }; +}; + +/** Flow Profiles, added in 8.2 at /service/profiles. */ +export const useProfiles = () => { + const api = useApi(); + const { profiles: supported, resolved } = 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: supported && resolved, + isLoading, + error, + }; +}; + +export const useProfile = (profileId) => { + const api = useApi(); + const { profiles: supported, resolved } = 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: supported && resolved, 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..49bae7f9 100644 --- a/src/stores/usePreferencesStore.jsx +++ b/src/stores/usePreferencesStore.jsx @@ -1,7 +1,147 @@ 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; + const known = new Set(persisted.map((column) => column.id)); + return [...persisted, ...defaults.filter((column) => !known.has(column.id))]; +}; + +const migratePreferences = (state) => { + if (!state) return state; + const migrated = { ...state }; + Object.entries(DEFAULT_COLUMNS_BY_KEY).forEach(([key, defaults]) => { + if (!migrated[key]) return; + migrated[key] = { + ...migrated[key], + contentDisplay: mergeColumns(migrated[key].contentDisplay, defaults), + }; + }); + return migrated; +}; const usePreferencesStore = create( persist( @@ -17,124 +157,64 @@ 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, + migrate: migratePreferences, } ) ); 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..cdfa3a43 --- /dev/null +++ b/src/utils/capabilities.js @@ -0,0 +1,37 @@ +// 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: init_segments Flow attribute and filter + initSegments: has82, + // 8.2: storage_backend_tag.{name} filters on segments and objects + storageBackendTags: has82, + // 8.2: editorial_purpose tag replaces the now-optional collection role + editorialPurpose: has82, + }; +}; diff --git a/src/utils/editorialPurpose.js b/src/utils/editorialPurpose.js new file mode 100644 index 00000000..903e71b8 --- /dev/null +++ b/src/utils/editorialPurpose.js @@ -0,0 +1,58 @@ +// 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", + PRIMARY: "primary", +}; + +// 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; +}; + +/** + * Describe a collected entity in the terms the store actually gave us: + * the 8.2 editorial_purpose tag where present, otherwise the legacy role. + */ +export const describeCollectionMember = (member, entity) => + getEditorialPurpose(entity) ?? member?.role ?? 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..e0efe6e9 --- /dev/null +++ b/src/utils/fetchEntityTree.js @@ -0,0 +1,66 @@ +import buildQuery from "@/utils/buildQuery"; +import paginationFetcher from "@/utils/paginationFetcher"; +import { TAMS_PAGE_LIMIT } from "@/constants"; + +// Keep collected_by_ids URLs to a sane length — each id is 36 characters. +const IDS_PER_REQUEST = 40; +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, and unlike the previous client-side tree it is correct across + * page boundaries. + */ +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); + + 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 + ) + ) + ); + + 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); + } + + return { items, hasMore: roots.hasMore }; +}; + +export default fetchEntityTree; diff --git a/src/utils/flowStatus.js b/src/utils/flowStatus.js new file mode 100644 index 00000000..57039a58 --- /dev/null +++ b/src/utils/flowStatus.js @@ -0,0 +1,58 @@ +// 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. Where no activity timestamp is available at all (list responses + * drop `segments_updated`) the reported status is taken at face value. + */ +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; +}; + +export const isFlowClosed = (flow) => + getFlowStatus(flow) === FLOW_STATUS.CLOSED_COMPLETE; diff --git a/src/utils/paginationFetcher.js b/src/utils/paginationFetcher.js index 248151b5..52dea5c4 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); @@ -15,12 +22,19 @@ const paginationFetcher = async (path, maxResults, api) => { } } + 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/views/Diagram/index.jsx b/src/views/Diagram/index.jsx index d42e67a1..4a56d532 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,12 +24,17 @@ const Diagram = () => { const [elements, setElements] = useState([]); const [error, setError] = useState(null); const api = useApi(); + const { collectedByIds: canQueryCollections } = useCapabilities(); useEffect(() => { const loadData = async () => { setError(null); try { - const elems = await getElements(api, `/${type}/${id}`); + const elems = await getElements( + api, + `/${type}/${id}`, + canQueryCollections + ); setElements(elems); cyRef.current?.fit(); } catch (err) { @@ -38,7 +44,7 @@ const Diagram = () => { loadData(); return () => cyRef.current?.removeAllListeners(); - }, [type, id, api]); + }, [type, id, api, canQueryCollections]); 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..f6547917 100644 --- a/src/views/Embed/index.jsx +++ b/src/views/Embed/index.jsx @@ -2,9 +2,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 +85,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 } = useCapabilities(); const [flowDetails, setFlowDetails] = useState(new Map()); const [isLoading, setIsLoading] = useState(false); @@ -125,6 +140,8 @@ const useSourceFlowDetails = (displayedSources) => { 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 +149,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 +194,7 @@ const useSourceFlowDetails = (displayedSources) => { fetchAll(); return () => { cancelled = true; }; - }, [api, displayedSources]); + }, [api, displayedSources, liveSourceIds, canQueryCollections]); // Poll growing flows by their flow ID every 5s useEffect(() => { @@ -231,7 +251,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 +290,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..2e93c69e --- /dev/null +++ b/src/views/Flow/components/ProfileTab.jsx @@ -0,0 +1,37 @@ +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, isLoading, error } = useProfile(profileId); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + {profile?.label || profileId} + + {error ? ( + + Could not load profile: {error.message} + + ) : ( + + )} + + ); +}; + +export default ProfileTab; diff --git a/src/views/Flow/components/SegmentsTab.jsx b/src/views/Flow/components/SegmentsTab.jsx index 26a5b4d4..c87746b2 100644 --- a/src/views/Flow/components/SegmentsTab.jsx +++ b/src/views/Flow/components/SegmentsTab.jsx @@ -1,6 +1,9 @@ +import { useState } from "react"; import { Box, CollectionPreferences, + FormField, + Select, SpaceBetween, Table, } from "@cloudscape-design/components"; @@ -9,11 +12,29 @@ import usePreferencesStore from "@/stores/usePreferencesStore"; import { SEGMENT_COUNT, DATE_FORMAT } from "@/constants"; import { parseTimerangeDateTime } from "@/utils/timerange"; 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 } = 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 +63,11 @@ const SegmentsTab = ({ flowId }) => { header: "Object Timerange", cell: (item) => item.object_timerange, }, + { + id: "init_object", + header: "Init Object", + cell: (item) => item.init_object?.object_id, + }, { id: "sample_offset", header: "Sample Offset", @@ -87,6 +113,46 @@ const SegmentsTab = ({ flowId }) => { return ( Showing last {SEGMENT_COUNT} segments + {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..808ed290 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,42 @@ 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 { 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 +110,49 @@ 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", + // essence_parameters.init_segments, not a top-level Flow attribute. + cell: (item) => + item.essence_parameters?.init_segments === undefined + ? null + : String(item.essence_parameters.init_segments), + sortingComparator: (a, b) => + Number(a.essence_parameters?.init_segments ?? false) - + Number(b.essence_parameters?.init_segments ?? false), + }, + { + id: "profile_id", + header: "Profile", + cell: (item) => + item.profile_id ? ( + {item.profile_id} + ) : null, + sortingField: "profile_id", + }, { id: "tags", header: "Tags", @@ -162,6 +242,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 +258,50 @@ 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, loadedCount, capabilities, isLoading, error } = + useFlows({ + viewMode, + hierarchical, + sortBy, + reverseOrder: sortBy + ? toReverseOrder(sortBy, sorting.isDescending) + : false, + status: statusFilter === ANY_STATUS ? undefined : statusFilter, + maxResults, + }); + + // Older stores have no status query parameter, so filter on the tag locally. + const visibleFlows = useMemo(() => { + if (!flows) return flows; + if (statusFilter === ANY_STATUS || capabilities.flowStatus) return flows; + return flows.filter((flow) => getFlowStatus(flow) === statusFilter); + }, [flows, statusFilter, capabilities.flowStatus]); + + 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,12 +316,7 @@ const Flows = () => { ), }, pagination: { pageSize: preferences.pageSize }, - sorting: { - defaultState: { - sortingColumn: columnDefinitions.find((col) => col.id === "created"), - isDescending: true, - }, - }, + sorting: { defaultState: defaultSorting }, selection: {}, }); const { selectedItems } = collectionProps; @@ -219,15 +336,54 @@ 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 +395,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..ffaf2f38 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,7 @@ const Layout = () => { }; const activeStore = useStoreManager((s) => s.getActiveStore()); + const { apiVersion, profiles: supportsProfiles, resolved } = useCapabilities(); const navHeader = { text: activeStore ? activeStore.name : "No store selected", @@ -65,6 +68,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 +83,16 @@ const Layout = () => { disabled: !activeStore, }, { type: "divider" }, - { type: "link", text: "Manage Stores", href: "/stores" }, + { + type: "link", + text: "Manage Stores", + href: "/stores", + 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..63b9d30a --- /dev/null +++ b/src/views/Profile/index.jsx @@ -0,0 +1,66 @@ +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, isLoading, error } = useProfile(profileId); + + 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..7e63786c --- /dev/null +++ b/src/views/Profiles/index.jsx @@ -0,0 +1,175 @@ +import { + Alert, + Box, + CollectionPreferences, + CopyToClipboard, + Header, + Pagination, + 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, 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, + }, + }, + } + ); + + 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..8ada5fca 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,32 @@ 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 { + 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 +106,12 @@ const columnDefinitions = [ cell: (item) => item.updated, sortingField: "updated", }, + { + id: "editorial_purpose", + header: "Editorial purpose", + cell: (item) => getEditorialPurpose(item), + sortingField: "editorial_purpose", + }, { id: "tags", header: "Tags", @@ -114,6 +147,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 +163,46 @@ 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, loadedCount, capabilities, 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,12 +217,7 @@ const Sources = () => { ), }, pagination: { pageSize: preferences.pageSize }, - sorting: { - defaultState: { - sortingColumn: columnDefinitions.find((col) => col.id === "created"), - isDescending: true, - }, - }, + sorting: { defaultState: defaultSorting }, selection: {}, }); @@ -170,15 +236,44 @@ const Sources = () => {
+ {hasMore && ( + + )} + setFormToken(detail.value)} - placeholder="Token" - type="password" - /> - - - ), - }, { id: "client_credentials", label: "Client / Secret", @@ -369,6 +361,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", From 4ff12310aa7f58c76e4fc6bf94f7ef15affa52d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 20:39:34 +0200 Subject: [PATCH 02/16] VI-5068 Show container_mapping on the flow collections tab Ported from upstream aws-samples/time-addressable-media-store-tools commit a753141 ("container_mapping on collections tab") by Dan Johns, adapted to this fork's JSX and to the reworked Collection component. Flow collection items carry a container_mapping describing where this Flow's essence sits in the collection's container. Like role, the column only appears when at least one item in the collection actually has it. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/Collection.jsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/Collection.jsx b/src/components/Collection.jsx index 097befa9..ca6100ed 100644 --- a/src/components/Collection.jsx +++ b/src/components/Collection.jsx @@ -43,6 +43,11 @@ const Collection = ({ entityType, collection, parentId }) => { ); 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 columnDefinitions = [ { @@ -75,6 +80,18 @@ const Collection = ({ entityType, collection, parentId }) => { }, ] : []), + ...(hasContainerMapping + ? [ + { + id: "container_mapping", + header: "Container mapping", + cell: (item) => + item.container_mapping + ? JSON.stringify(item.container_mapping) + : "", + }, + ] + : []), ]; if (!collection) return `No ${entityType} collection(s)`; From c07d0114245156e10021e3d3c3ac7d4691540102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 21:33:30 +0200 Subject: [PATCH 03/16] VI-5068 Apply status filter and sort locally in hierarchical view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hierarchical mode keys SWR on "tree" and fetches through fetchEntityTree, which builds its own queries and never receives status, sort_by or reverse_order. The Flows view then skipped its client-side status filter whenever capabilities.flowStatus was true, so on an 8.2 store selecting a status changed nothing: no refetch (the key was unchanged) and no local filter. Hierarchy is on by default, so this was the default path. Expose treeMode from useEntityListing and fall back to local filtering whenever the query was not sent, rather than keying off store capability alone. 8.0 stores are unaffected — treeMode is false there, so they keep filtering locally as before. Both headers claimed the store did the filtering and sorting; they now say what actually happens in each of the three cases. Co-Authored-By: Claude Opus 5 (1M context) --- src/hooks/useEntityListing.jsx | 5 +++++ src/views/Flows/index.jsx | 31 ++++++++++++++++++++----------- src/views/Sources/index.jsx | 23 +++++++++++++++-------- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/hooks/useEntityListing.jsx b/src/hooks/useEntityListing.jsx index 0b0a018c..c1d56dcb 100644 --- a/src/hooks/useEntityListing.jsx +++ b/src/hooks/useEntityListing.jsx @@ -36,6 +36,10 @@ const COLLECTION_FIELD = { * `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 { @@ -97,6 +101,7 @@ export const useEntityListing = (entityType, options = {}) => { hasMore: data?.hasMore ?? false, loadedCount: data?.items.length ?? 0, capabilities, + treeMode, mutate, isLoading: isLoading || !capabilities.resolved, isValidating, diff --git a/src/views/Flows/index.jsx b/src/views/Flows/index.jsx index 808ed290..406e6421 100644 --- a/src/views/Flows/index.jsx +++ b/src/views/Flows/index.jsx @@ -272,7 +272,7 @@ const Flows = () => { const hierarchical = showHierarchy && viewMode === VIEW_MODE.ALL; - const { flows, hasMore, loadedCount, capabilities, isLoading, error } = + const { flows, hasMore, loadedCount, capabilities, treeMode, isLoading, error } = useFlows({ viewMode, hierarchical, @@ -284,12 +284,14 @@ const Flows = () => { maxResults, }); - // Older stores have no status query parameter, so filter on the tag locally. + // 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 || capabilities.flowStatus) return flows; + if (statusFilter === ANY_STATUS || serverFiltered) return flows; return flows.filter((flow) => getFlowStatus(flow) === statusFilter); - }, [flows, statusFilter, capabilities.flowStatus]); + }, [flows, statusFilter, serverFiltered]); const loadedIds = useMemo( () => new Set((visibleFlows ?? []).map((flow) => flow.id)), @@ -321,6 +323,19 @@ const Flows = () => { }); const { selectedItems } = collectionProps; + let description; + 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."; + } + if (error) { return ( @@ -339,13 +354,7 @@ const Flows = () => { counter={ isLoading ? undefined : `(${loadedCount}${hasMore ? "+" : ""})` } - description={ - capabilities.flowStatus - ? "Status comes from the flow's status attribute; filtering and sorting are done by the store." - : `Store reports TAMS ${ - capabilities.apiVersion ?? "8.0 or earlier" - }; status is read from the deprecated flow_status tag.` - } + description={description} actions={ { // The hierarchy needs children in the item set, which the scoped views exclude. const hierarchical = showHierarchy && viewMode === VIEW_MODE.ALL; - const { sources, hasMore, loadedCount, capabilities, isLoading, error } = + const { sources, hasMore, loadedCount, capabilities, treeMode, isLoading, error } = useSources({ viewMode, hierarchical, @@ -221,6 +221,19 @@ const Sources = () => { selection: {}, }); + let description; + 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 (error) { return ( @@ -239,13 +252,7 @@ const Sources = () => { counter={ isLoading ? undefined : `(${loadedCount}${hasMore ? "+" : ""})` } - description={ - capabilities.sortBy - ? "Sorted by the store. Sort on Created, Updated or Label to re-query; other columns sort the loaded rows." - : `Store reports TAMS ${ - capabilities.apiVersion ?? "8.0 or earlier" - }; all sources are loaded and sorted in the browser.` - } + description={description} actions={ Date: Tue, 18 Aug 2026 21:35:30 +0200 Subject: [PATCH 04/16] VI-5068 Distinguish an unreachable /service from an 8.0 store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useCapabilities resolved a failed /service to the 8.0 baseline and returned an error nobody read — all eleven consumers destructured only the flags. The UI then stated as fact that the store "reports TAMS 8.0 or earlier" and that it "does not advertise support" for Profiles, about a store that had answered nothing. With shouldRetryOnError disabled, one transient failure pinned the session to the baseline until reload. Add detectionFailed so callers can tell "we could not ask" from "the store said 8.0", and use it wherever the UI states a version. Re-enable error retry, since this single request gates every 8.2 feature. The Profiles views also checked supported before resolved, so an 8.2 store showed "Flow Profiles are not available" for the duration of the /service request; they now wait for the answer before reporting one. Co-Authored-By: Claude Opus 5 (1M context) --- src/hooks/useService.jsx | 20 +++++++++++++++----- src/views/Flows/index.jsx | 4 +++- src/views/Layout.jsx | 11 +++++++++-- src/views/Profile/index.jsx | 21 ++++++++++++++++++++- src/views/Profiles/index.jsx | 22 +++++++++++++++++++++- src/views/Sources/index.jsx | 4 +++- src/views/StoreManager/index.jsx | 5 ++++- 7 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/hooks/useService.jsx b/src/hooks/useService.jsx index a5db83d9..10f49cea 100644 --- a/src/hooks/useService.jsx +++ b/src/hooks/useService.jsx @@ -12,7 +12,9 @@ export const useService = () => { { revalidateOnFocus: false, revalidateIfStale: false, - shouldRetryOnError: 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. + shouldRetryOnError: true, } ); @@ -26,6 +28,11 @@ export const useService = () => { * 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(); @@ -35,6 +42,7 @@ export const useCapabilities = () => { ...capabilities, service, error, + detectionFailed: Boolean(error), resolved: !isLoading, }; }; @@ -83,7 +91,7 @@ export const useStorageBackends = () => { /** Flow Profiles, added in 8.2 at /service/profiles. */ export const useProfiles = () => { const api = useApi(); - const { profiles: supported, resolved } = useCapabilities(); + const { profiles: supported, resolved, detectionFailed } = useCapabilities(); const { data, error, isLoading } = useSWR( api.endpoint && resolved && supported @@ -95,7 +103,9 @@ export const useProfiles = () => { return { profiles: Array.isArray(data?.data) ? data.data : undefined, - supported: supported && resolved, + supported, + resolved, + detectionFailed, isLoading, error, }; @@ -103,7 +113,7 @@ export const useProfiles = () => { export const useProfile = (profileId) => { const api = useApi(); - const { profiles: supported, resolved } = useCapabilities(); + const { profiles: supported, resolved, detectionFailed } = useCapabilities(); const { data, error, isLoading } = useSWR( api.endpoint && resolved && supported && profileId @@ -113,5 +123,5 @@ export const useProfile = (profileId) => { { revalidateOnFocus: false, shouldRetryOnError: false } ); - return { profile: data?.data, supported: supported && resolved, isLoading, error }; + return { profile: data?.data, supported, resolved, detectionFailed, isLoading, error }; }; diff --git a/src/views/Flows/index.jsx b/src/views/Flows/index.jsx index 406e6421..63cb128f 100644 --- a/src/views/Flows/index.jsx +++ b/src/views/Flows/index.jsx @@ -324,7 +324,9 @@ const Flows = () => { const { selectedItems } = collectionProps; let description; - if (!capabilities.flowStatus) { + 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.`; diff --git a/src/views/Layout.jsx b/src/views/Layout.jsx index ffaf2f38..16d5530d 100644 --- a/src/views/Layout.jsx +++ b/src/views/Layout.jsx @@ -58,7 +58,12 @@ const Layout = () => { }; const activeStore = useStoreManager((s) => s.getActiveStore()); - const { apiVersion, profiles: supportsProfiles, resolved } = useCapabilities(); + const { + apiVersion, + profiles: supportsProfiles, + resolved, + detectionFailed, + } = useCapabilities(); const navHeader = { text: activeStore ? activeStore.name : "No store selected", @@ -87,8 +92,10 @@ const Layout = () => { 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 ?"} ), diff --git a/src/views/Profile/index.jsx b/src/views/Profile/index.jsx index 63b9d30a..c2f28ed8 100644 --- a/src/views/Profile/index.jsx +++ b/src/views/Profile/index.jsx @@ -14,7 +14,26 @@ import { useProfile } from "@/hooks/useService"; const Profile = () => { const { profileId } = useParams(); - const { profile, supported, isLoading, error } = useProfile(profileId); + 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 ( diff --git a/src/views/Profiles/index.jsx b/src/views/Profiles/index.jsx index 7e63786c..e635d801 100644 --- a/src/views/Profiles/index.jsx +++ b/src/views/Profiles/index.jsx @@ -5,6 +5,7 @@ import { CopyToClipboard, Header, Pagination, + Spinner, Table, TextFilter, } from "@cloudscape-design/components"; @@ -96,7 +97,8 @@ const Profiles = () => { const setPreferences = usePreferencesStore( (state) => state.setProfilesPreferences ); - const { profiles, supported, isLoading, error } = useProfiles(); + const { profiles, supported, resolved, detectionFailed, isLoading, error } = + useProfiles(); const { items, collectionProps, filterProps, paginationProps } = useCollection( isLoading || error ? [] : profiles ?? [], @@ -123,6 +125,24 @@ const Profiles = () => { } ); + // 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 ( diff --git a/src/views/Sources/index.jsx b/src/views/Sources/index.jsx index 0b8662ef..98fe76d3 100644 --- a/src/views/Sources/index.jsx +++ b/src/views/Sources/index.jsx @@ -222,7 +222,9 @@ const Sources = () => { }); let description; - if (!capabilities.sortBy) { + 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.`; diff --git a/src/views/StoreManager/index.jsx b/src/views/StoreManager/index.jsx index ce499f7e..936aed82 100644 --- a/src/views/StoreManager/index.jsx +++ b/src/views/StoreManager/index.jsx @@ -33,7 +33,7 @@ const StoreManager = () => { const updateStore = useStoreManager((s) => s.updateStore); const setActiveStore = useStoreManager((s) => s.setActiveStore); // Only the active store is queried, so the version is only known for that row. - const { apiVersion, resolved } = useCapabilities(); + const { apiVersion, resolved, detectionFailed } = useCapabilities(); const [modalVisible, setModalVisible] = useState(false); const [editingStore, setEditingStore] = useState(null); @@ -219,6 +219,9 @@ const StoreManager = () => { cell: (item) => { if (item.id !== activeStoreId) return "—"; if (!resolved) return …; + if (detectionFailed) { + return Unreachable; + } return apiVersion ?? "Unknown"; }, }, From d0904fea26846603b6fe5c90bdce15293284a65e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 21:36:00 +0200 Subject: [PATCH 05/16] VI-5068 Gate the Diagram traversal on resolved capabilities canQueryCollections was added to the effect deps but useCapabilities reports false until /service resolves, so every diagram open ran the traversal twice: first the slow per-member walk, then the batched one. Neither run was cancellable and both called setElements, so the slower un-prefetched run could land last and win. Wait for resolved before traversing, and guard setElements with the usual cancelled flag. Co-Authored-By: Claude Opus 5 (1M context) --- src/views/Diagram/index.jsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/views/Diagram/index.jsx b/src/views/Diagram/index.jsx index 4a56d532..363bd74f 100644 --- a/src/views/Diagram/index.jsx +++ b/src/views/Diagram/index.jsx @@ -24,9 +24,15 @@ const Diagram = () => { const [elements, setElements] = useState([]); const [error, setError] = useState(null); const api = useApi(); - const { collectedByIds: canQueryCollections } = useCapabilities(); + 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 { @@ -35,16 +41,20 @@ const Diagram = () => { `/${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, canQueryCollections]); + return () => { + cancelled = true; + cyRef.current?.removeAllListeners(); + }; + }, [type, id, api, canQueryCollections, resolved]); const handleZoom = (action) => { const zoom = cyRef.current?.zoom(); From cf83e384271b0faa2c1d29a78f35de1067b29df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 21:38:01 +0200 Subject: [PATCH 06/16] VI-5068 Recognise the pre-8.2 init segment convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Init segment support was added in the 8.2 shape only: the Flows column read essence_parameters.init_segments and the Segments tab read init_object off each segment. Pre-8.2 stores — including the sonytams store on labs — name the init object in the tags.init_segment Flow tag and list it at the head of the segment listing as a zero-length-timerange entry. Against those stores the column therefore read blank for flows that do have an init segment, and the init object rendered as an ordinary media segment. Add hasInitSegments/isInitSegmentEntry covering both conventions, and badge the pre-8.2 entry so it is not mistaken for playable media. Zero-length detection is via parseTimerange, so "()", "[0:0_0:0)" and a bare timestamp all count while infinite and absent timeranges do not. Co-Authored-By: Claude Opus 5 (1M context) --- src/utils/initSegments.js | 32 +++++++++++++++++++++++ src/views/Flow/components/SegmentsTab.jsx | 8 +++++- src/views/Flows/index.jsx | 15 ++++++----- 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 src/utils/initSegments.js diff --git a/src/utils/initSegments.js b/src/utils/initSegments.js new file mode 100644 index 00000000..541b2244 --- /dev/null +++ b/src/utils/initSegments.js @@ -0,0 +1,32 @@ +import { parseTimerange } 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; + if (declared !== undefined) 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. + */ +export const isInitSegmentEntry = (segment) => { + if (segment?.init_object) return false; + + const { timerange } = segment ?? {}; + if (typeof timerange !== "string" || !timerange) return false; + + const { start, end } = parseTimerange(timerange); + return start !== null && end !== null && start === end; +}; diff --git a/src/views/Flow/components/SegmentsTab.jsx b/src/views/Flow/components/SegmentsTab.jsx index c87746b2..a752c2d2 100644 --- a/src/views/Flow/components/SegmentsTab.jsx +++ b/src/views/Flow/components/SegmentsTab.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { + Badge, Box, CollectionPreferences, FormField, @@ -11,6 +12,7 @@ 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"; @@ -66,7 +68,11 @@ const SegmentsTab = ({ flowId }) => { { id: "init_object", header: "Init Object", - cell: (item) => item.init_object?.object_id, + // 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", diff --git a/src/views/Flows/index.jsx b/src/views/Flows/index.jsx index 63cb128f..e2c2abc6 100644 --- a/src/views/Flows/index.jsx +++ b/src/views/Flows/index.jsx @@ -21,6 +21,7 @@ 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 { FLOW_STATUS_VALUES, getFlowStatus } from "@/utils/flowStatus"; import { FLOW_STATUS_MAPPINGS, @@ -135,14 +136,16 @@ const columnDefinitions = [ { id: "init_segments", header: "Init segments", - // essence_parameters.init_segments, not a top-level Flow attribute. - cell: (item) => - item.essence_parameters?.init_segments === undefined + // 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"; + return item.essence_parameters?.init_segments === undefined ? null - : String(item.essence_parameters.init_segments), + : "false"; + }, sortingComparator: (a, b) => - Number(a.essence_parameters?.init_segments ?? false) - - Number(b.essence_parameters?.init_segments ?? false), + Number(hasInitSegments(a)) - Number(hasInitSegments(b)), }, { id: "profile_id", From a46d970988d01b697a9e992bd6f43b3ef679e3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 21:39:38 +0200 Subject: [PATCH 07/16] VI-5068 Report an incomplete hierarchy instead of truncating silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchEntityTree stopped after MAX_DEPTH levels with a possibly non-empty frontier, and discarded each batch's hasMore when flattening the responses. Both losses were invisible: returned hasMore reflected only the root listing, and Cloudscape draws no expand control for a row whose children are absent, so a truncated branch looks exactly like an empty collection. Track both and return `truncated`, which the listing views append to their header description. Also fold per-batch hasMore into the returned hasMore so Load more actually reaches the missing children. Sources' Editorial purpose column sorted on a top-level field that does not exist — the value comes from tags, which is why the cell goes through getEditorialPurpose. Use a comparator, as the Flows column already does. Co-Authored-By: Claude Opus 5 (1M context) --- src/hooks/useEntityListing.jsx | 1 + src/utils/fetchEntityTree.js | 28 ++++++++++++++++++++++----- src/views/Flows/index.jsx | 17 +++++++++++++++-- src/views/Sources/index.jsx | 35 +++++++++++++++++++++++----------- 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/hooks/useEntityListing.jsx b/src/hooks/useEntityListing.jsx index c1d56dcb..7e87ff52 100644 --- a/src/hooks/useEntityListing.jsx +++ b/src/hooks/useEntityListing.jsx @@ -99,6 +99,7 @@ export const useEntityListing = (entityType, options = {}) => { return { items, hasMore: data?.hasMore ?? false, + truncated: data?.truncated ?? false, loadedCount: data?.items.length ?? 0, capabilities, treeMode, diff --git a/src/utils/fetchEntityTree.js b/src/utils/fetchEntityTree.js index e0efe6e9..6c128337 100644 --- a/src/utils/fetchEntityTree.js +++ b/src/utils/fetchEntityTree.js @@ -2,9 +2,12 @@ import buildQuery from "@/utils/buildQuery"; import paginationFetcher from "@/utils/paginationFetcher"; import { TAMS_PAGE_LIMIT } from "@/constants"; -// Keep collected_by_ids URLs to a sane length — each id is 36 characters. +// 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; -const MAX_DEPTH = 4; +// 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 = []; @@ -22,8 +25,12 @@ const batchIds = (ids, size) => { * 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, and unlike the previous client-side tree it is correct across - * page boundaries. + * 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}`, { @@ -35,6 +42,7 @@ const fetchEntityTree = async (entityType, api, maxResults = TAMS_PAGE_LIMIT) => 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); @@ -51,6 +59,8 @@ const fetchEntityTree = async (entityType, api, maxResults = TAMS_PAGE_LIMIT) => ) ); + if (responses.some((response) => response.hasMore)) childrenTruncated = true; + const fresh = responses .flatMap((response) => response.items) .filter((item) => !seen.has(item.id)); @@ -60,7 +70,15 @@ const fetchEntityTree = async (entityType, api, maxResults = TAMS_PAGE_LIMIT) => frontier = fresh.map((item) => item.id); } - return { items, hasMore: roots.hasMore }; + // A non-empty frontier here means the depth cap stopped the descent. + const depthExceeded = frontier.length > 0; + + return { + items, + hasMore: roots.hasMore || childrenTruncated, + truncated: depthExceeded || childrenTruncated, + depthExceeded, + }; }; export default fetchEntityTree; diff --git a/src/views/Flows/index.jsx b/src/views/Flows/index.jsx index e2c2abc6..dc734868 100644 --- a/src/views/Flows/index.jsx +++ b/src/views/Flows/index.jsx @@ -22,6 +22,7 @@ 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, @@ -275,8 +276,16 @@ const Flows = () => { const hierarchical = showHierarchy && viewMode === VIEW_MODE.ALL; - const { flows, hasMore, loadedCount, capabilities, treeMode, isLoading, error } = - useFlows({ + const { + flows, + hasMore, + truncated, + loadedCount, + capabilities, + treeMode, + isLoading, + error, + } = useFlows({ viewMode, hierarchical, sortBy, @@ -341,6 +350,10 @@ const Flows = () => { "Status comes from the flow's status attribute; the store applies the status filter and the Created, Metadata updated and Label sorts."; } + if (truncated) { + description = `${description} The hierarchy is incomplete: it is nested deeper than ${MAX_DEPTH} levels, or a collection has more children than one page.`; + } + if (error) { return ( diff --git a/src/views/Sources/index.jsx b/src/views/Sources/index.jsx index 98fe76d3..358ed231 100644 --- a/src/views/Sources/index.jsx +++ b/src/views/Sources/index.jsx @@ -20,6 +20,7 @@ import { useSources } from "@/hooks/useSources"; import { SERVER_SORT_FIELDS, toReverseOrder } from "@/hooks/useEntityListing"; import usePreferencesStore from "@/stores/usePreferencesStore"; import { getEditorialPurpose } from "@/utils/editorialPurpose"; +import { MAX_DEPTH } from "@/utils/fetchEntityTree"; import { PAGE_SIZE_PREFERENCE, RESULT_PAGE_SIZE, @@ -109,8 +110,10 @@ const columnDefinitions = [ { id: "editorial_purpose", header: "Editorial purpose", + // Lives under tags, so there is no top-level field to sort on. cell: (item) => getEditorialPurpose(item), - sortingField: "editorial_purpose", + sortingComparator: (a, b) => + (getEditorialPurpose(a) ?? "").localeCompare(getEditorialPurpose(b) ?? ""), }, { id: "tags", @@ -179,16 +182,22 @@ const Sources = () => { // The hierarchy needs children in the item set, which the scoped views exclude. const hierarchical = showHierarchy && viewMode === VIEW_MODE.ALL; - const { sources, hasMore, loadedCount, capabilities, treeMode, isLoading, error } = - useSources({ - viewMode, - hierarchical, - sortBy, - reverseOrder: sortBy - ? toReverseOrder(sortBy, sorting.isDescending) - : false, - maxResults, - }); + 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)), @@ -236,6 +245,10 @@ const Sources = () => { "Sorted by the store. Sort on Created, Updated or Label to re-query; other columns sort the loaded rows."; } + if (truncated) { + description = `${description} The hierarchy is incomplete: it is nested deeper than ${MAX_DEPTH} levels, or a collection has more children than one page.`; + } + if (error) { return ( From 9108c130b2f3beecb5553fbed7c9ddde024c9d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 21:40:31 +0200 Subject: [PATCH 08/16] VI-5068 Judge ingest activity on segments_updated only The docstring promised that a Flow with no activity timestamp would have its status taken at face value, but `?? flow.created` meant that branch was unreachable: `created` is present on every Flow, so the 10-minute staleness window was applied to creation time. A genuinely live ingest older than ten minutes read as not growing, and in the Embed that caches the source as closed for the rest of the session. `segments_updated` is the only field that measures ingest activity, so drop the `created` fallback and let the code do what the comment says. Where the store does supply `segments_updated` the guard is unchanged; where it does not, an ingesting Flow now shows as live rather than silently disappearing. Also record the paginationFetcher coupling: it strips `segments_updated`, so a Flow from a listing always takes the face-value path. Co-Authored-By: Claude Opus 5 (1M context) --- src/utils/flowStatus.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/utils/flowStatus.js b/src/utils/flowStatus.js index 57039a58..0afdf0c9 100644 --- a/src/utils/flowStatus.js +++ b/src/utils/flowStatus.js @@ -33,8 +33,14 @@ export const getFlowStatus = (flow) => { * 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. Where no activity timestamp is available at all (list responses - * drop `segments_updated`) the reported status is taken at face value. + * abandoned. + * + * `segments_updated` is the only field that measures ingest activity, so where + * it is absent the reported status is taken at face value rather than guessed + * at from `created` — a long-running ingest is not a stale one. Note that + * paginationFetcher strips `segments_updated`, so a Flow from a listing always + * takes the face-value path; pass a Flow from the detail endpoint where the + * distinction matters. */ export const isFlowGrowing = (flow, now = Date.now()) => { const status = getFlowStatus(flow); @@ -46,7 +52,7 @@ export const isFlowGrowing = (flow, now = Date.now()) => { return false; } - const lastActivity = flow?.segments_updated ?? flow?.created; + const lastActivity = flow?.segments_updated; if (!lastActivity) return true; const timestamp = new Date(lastActivity).getTime(); From 872965bfbed4a4a49012a7b00ee7c39e8063f771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 21:41:40 +0200 Subject: [PATCH 09/16] VI-5068 Merge new columns on every rehydrate, not only on a version bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doing the column merge in zustand's `migrate` meant it only ran when the persisted version differed from the configured one. It works for this release's 0 -> 1 bump, but once a user has loaded this build their storage is stamped version 1, so the next release that adds a column silently reintroduces exactly the bug this code exists to fix — and nothing signals that a bump is required. Do the merge in `merge` instead, which zustand calls on every rehydrate. mergeColumns only appends unknown ids, so it is idempotent. `migrate` is kept because without it zustand discards the entire persisted state of anyone whose storage predates `version`. Verified against zustand 5.0.11 for three cases: version 0 storage gaining this release's columns, version 1 storage gaining a later release's column with no bump, and a repeat load adding no duplicates. User column order and visibility are preserved in all three. Co-Authored-By: Claude Opus 5 (1M context) --- src/stores/usePreferencesStore.jsx | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/stores/usePreferencesStore.jsx b/src/stores/usePreferencesStore.jsx index 49bae7f9..4564e556 100644 --- a/src/stores/usePreferencesStore.jsx +++ b/src/stores/usePreferencesStore.jsx @@ -130,17 +130,17 @@ const mergeColumns = (persisted, defaults) => { return [...persisted, ...defaults.filter((column) => !known.has(column.id))]; }; -const migratePreferences = (state) => { +const withNewColumns = (state) => { if (!state) return state; - const migrated = { ...state }; + const merged = { ...state }; Object.entries(DEFAULT_COLUMNS_BY_KEY).forEach(([key, defaults]) => { - if (!migrated[key]) return; - migrated[key] = { - ...migrated[key], - contentDisplay: mergeColumns(migrated[key].contentDisplay, defaults), + if (!merged[key]) return; + merged[key] = { + ...merged[key], + contentDisplay: mergeColumns(merged[key].contentDisplay, defaults), }; }); - return migrated; + return merged; }; const usePreferencesStore = create( @@ -214,7 +214,13 @@ const usePreferencesStore = create( { name: "tams-ui-preferences", version: 1, - migrate: migratePreferences, + // 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: without it, zustand discards the whole persisted state of + // anyone whose storage predates `version`. + migrate: withNewColumns, } ) ); From 9e7cea49f8a4c9cbd2ad57d57001c422190645bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 21:43:26 +0200 Subject: [PATCH 10/16] VI-5068 Remove unreachable plumbing and correct the README None of this was reachable: describeCollectionMember, isFlowClosed and EDITORIAL_PURPOSE.PRIMARY had no callers, capabilities.editorialPurpose was never read (getEditorialPurpose just reads a tag, which works on any store), and the profileId/initSegments options on useEntityListing were gated and threaded into the query but never passed by any view. With no lint in this repo nothing else would have caught them. The README claimed 8.0/8.1 were "fully supported" and documented an init_segments filter that has no UI; it now says what actually degrades and describes init_segments as the column it is. Also note that include_object_timerange is sent unconditionally on segment listings rather than gated, since the README claimed otherwise. Two capability comments were wrong: init_segments is declared under essence_parameters, and storage_backend_tag is used for segments only. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 22 +++++++++++++--------- src/hooks/useEntityListing.jsx | 4 ---- src/utils/capabilities.js | 6 +----- src/utils/editorialPurpose.js | 7 ------- src/utils/flowStatus.js | 2 -- 5 files changed, 14 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index f845e2b7..454768a9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 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 only the multi-sources that collect others +- **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 @@ -18,12 +18,16 @@ A free, public web tool for browsing [Time-Addressable Media Store (TAMS)](https ## TAMS version support -Targets **TAMS 8.2**; TAMS 8.0 and 8.1 stores are fully supported. +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 only sends -newer query parameters to stores that advertise support for them, 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. +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: @@ -33,10 +37,10 @@ shown beside **Manage Stores** in the sidebar and in the store list. | `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 | -| `init_segments` | Flow attribute display and filter | +| `/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 | +| `include_object_timerange` | Webhook registration option (also sent on segment listings, which pre-8.2 stores ignore) | ## Quick Start diff --git a/src/hooks/useEntityListing.jsx b/src/hooks/useEntityListing.jsx index 7e87ff52..b91c260f 100644 --- a/src/hooks/useEntityListing.jsx +++ b/src/hooks/useEntityListing.jsx @@ -48,8 +48,6 @@ export const useEntityListing = (entityType, options = {}) => { sortBy, reverseOrder = false, status, - profileId, - initSegments, maxResults = RESULT_PAGE_SIZE, refreshInterval, } = options; @@ -66,8 +64,6 @@ export const useEntityListing = (entityType, options = {}) => { reverse_order: capabilities.sortBy && reverseOrder ? true : undefined, collected_by_ids: scoped ? "" : undefined, status: capabilities.flowStatus ? status : undefined, - profile_id: capabilities.profiles ? profileId : undefined, - init_segments: capabilities.initSegments ? initSegments : undefined, }); const limit = capabilities.sortBy ? maxResults : null; diff --git a/src/utils/capabilities.js b/src/utils/capabilities.js index cdfa3a43..941536bf 100644 --- a/src/utils/capabilities.js +++ b/src/utils/capabilities.js @@ -27,11 +27,7 @@ export const getCapabilities = (apiVersion) => { flowStatus: has82, // 8.2: /service/profiles and the profile_id Flow filter profiles: has82, - // 8.2: init_segments Flow attribute and filter - initSegments: has82, - // 8.2: storage_backend_tag.{name} filters on segments and objects + // 8.2: storage_backend_tag.{name} filters on segments storageBackendTags: has82, - // 8.2: editorial_purpose tag replaces the now-optional collection role - editorialPurpose: has82, }; }; diff --git a/src/utils/editorialPurpose.js b/src/utils/editorialPurpose.js index 903e71b8..95d410ba 100644 --- a/src/utils/editorialPurpose.js +++ b/src/utils/editorialPurpose.js @@ -6,7 +6,6 @@ export const EDITORIAL_PURPOSE = { PROGRAMME: "programme", PROGRAMME_SIGNED: "programme_signed", CLEANFEED: "cleanfeed", - PRIMARY: "primary", }; // Preference order when looking for the main video of a multi-Source. @@ -26,12 +25,6 @@ export const getEditorialPurpose = (entity) => { return null; }; -/** - * Describe a collected entity in the terms the store actually gave us: - * the 8.2 editorial_purpose tag where present, otherwise the legacy role. - */ -export const describeCollectionMember = (member, entity) => - getEditorialPurpose(entity) ?? member?.role ?? null; /** * Pick the video entity from a set of collected Sources or Flows. diff --git a/src/utils/flowStatus.js b/src/utils/flowStatus.js index 0afdf0c9..1a148b90 100644 --- a/src/utils/flowStatus.js +++ b/src/utils/flowStatus.js @@ -60,5 +60,3 @@ export const isFlowGrowing = (flow, now = Date.now()) => { return now - timestamp < GROWING_STALE_MS; }; -export const isFlowClosed = (flow) => - getFlowStatus(flow) === FLOW_STATUS.CLOSED_COMPLETE; From 986e3ce8e3c5c4336d9a8f5ff3d68fdd46fc7ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 21:43:45 +0200 Subject: [PATCH 11/16] VI-5068 Run the build on pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy.yml triggers only on push to main, so nothing verified a pull request and the first build of a change ran as part of the public Pages deploy — a broken build took the site down rather than failing a check. Same steps as the deploy job's build, minus the artifact upload. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From 44853ee410a16a217b7f50a064b51cbaaa2dbe11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 22:01:25 +0200 Subject: [PATCH 12/16] VI-5068 Resolve all 14 npm audit vulnerabilities Lockfile-only: every advisory resolved to a version already permitted by the existing semver ranges, so package.json is unchanged and no peer ranges moved. axios 1.13.6 -> 1.19.0 10 high (DoS, prototype pollution, SSRF) react-router-dom 7.13.1 -> 7.18.2 12 high (XSS, open redirect, CSRF, DoS) vite 6.4.1 -> 6.4.3 server.fs.deny bypass, NTLM disclosure postcss 8.5.25 -> 8.5.26 sourceMappingURL arbitrary .map read js-yaml, nanoid, brace-expansion, form-data, tmp, @babel/core npm audit now reports 0 vulnerabilities, down from 14 (10 high, 3 moderate, 1 low). axios was reported as fixAvailable:false but omakase-player declares a ^1.13.5 range rather than a pin, so the bump was in fact permitted. This also settles a pre-existing lockfile inconsistency: package.json asked for vite ^6.4.2 while the lockfile pinned 6.4.1, so npm ls reported the tree as invalid. Side effect worth noting: eslint-plugin-react-hooks now resolves its own zod@4.4.3 instead of colliding with omakase-player's hoisted zod@3.23.8, so the plugin loads. A missing flat config is now the only thing stopping npm run lint from working. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 317 +++++++++++++++++++++++----------------------- 1 file changed, 160 insertions(+), 157 deletions(-) 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": { From c102dd8331708e546753653f1130e1c5b05aa55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 22:12:15 +0200 Subject: [PATCH 13/16] VI-5068 Restore the created-time grace window in isFlowGrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9108c13 resolved the docstring-vs-code mismatch by deleting the `created` fallback. That was the wrong half to change. The fallback only ever applied to Flows with no `segments_updated` at all — precisely the abandoned-ingest case the guard exists for — so removing it meant a Flow whose status is stuck on "ingesting" and which never received a segment reported itself live forever. In the Embed that is a latch: growingRef only drains when isFlowGrowing goes false, so such a Flow was also re-polled every 5s for the life of the page. A long-running ingest that is genuinely receiving content has `segments_updated`, so it never reached the fallback and was never at risk of being aged out — the problem the removal was meant to solve did not exist. Restore the fallback and fix the comment instead, which is what the original finding actually called for. Co-Authored-By: Claude Opus 5 (1M context) --- src/utils/flowStatus.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/utils/flowStatus.js b/src/utils/flowStatus.js index 1a148b90..4feb1dd2 100644 --- a/src/utils/flowStatus.js +++ b/src/utils/flowStatus.js @@ -35,12 +35,16 @@ export const getFlowStatus = (flow) => { * authoritative, so an ingesting Flow that has not moved recently is treated as * abandoned. * - * `segments_updated` is the only field that measures ingest activity, so where - * it is absent the reported status is taken at face value rather than guessed - * at from `created` — a long-running ingest is not a stale one. Note that - * paginationFetcher strips `segments_updated`, so a Flow from a listing always - * takes the face-value path; pass a Flow from the detail endpoint where the - * distinction matters. + * `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); @@ -52,7 +56,7 @@ export const isFlowGrowing = (flow, now = Date.now()) => { return false; } - const lastActivity = flow?.segments_updated; + const lastActivity = flow?.segments_updated ?? flow?.created; if (!lastActivity) return true; const timestamp = new Date(lastActivity).getTime(); From 3b5d9ac3c7c0a767a1e0fe963a86a6ead05ea101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 22:13:54 +0200 Subject: [PATCH 14/16] VI-5068 Stop badging corrupt timeranges as init segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isInitSegmentEntry trusted parseTimerange's start === end, but that function collapses three different situations onto the same 0n..0n result: a genuine zero-length range, a string that does not match the regex at all, and an inverted range where start > end. So a media segment whose timerange was merely corrupt was badged "init segment" — told to the user as a positive claim that it carries no playable media, which is the exact failure mode this file was added to remove. Read the bounds from the regex groups instead, so only a range that is demonstrably well-formed and zero-length qualifies. Verified across 22 cases, including the two that previously misfired ("not-a-date" and "[10:0_5:0)"). Also treat an explicit essence_parameters.init_segments of null as "the store did not say" rather than as false, matching the blank the column reserves for unknown. Co-Authored-By: Claude Opus 5 (1M context) --- src/utils/editorialPurpose.js | 1 - src/utils/initSegments.js | 26 ++++++++++++++++++++++---- src/utils/timerange.js | 4 ++-- src/views/Flows/index.jsx | 5 ++--- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/utils/editorialPurpose.js b/src/utils/editorialPurpose.js index 95d410ba..46b24f34 100644 --- a/src/utils/editorialPurpose.js +++ b/src/utils/editorialPurpose.js @@ -25,7 +25,6 @@ export const getEditorialPurpose = (entity) => { return null; }; - /** * Pick the video entity from a set of collected Sources or Flows. * diff --git a/src/utils/initSegments.js b/src/utils/initSegments.js index 541b2244..515e4c50 100644 --- a/src/utils/initSegments.js +++ b/src/utils/initSegments.js @@ -1,4 +1,4 @@ -import { parseTimerange } from "@/utils/timerange"; +import { EMPTY_PATTERNS, TIMERANGE_REGEX } from "@/utils/timerange"; /** * Two conventions exist for init segments. @@ -10,7 +10,9 @@ import { parseTimerange } from "@/utils/timerange"; */ export const hasInitSegments = (flow) => { const declared = flow?.essence_parameters?.init_segments; - if (declared !== undefined) return Boolean(declared); + // `== 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; @@ -20,13 +22,29 @@ export const hasInitSegments = (flow) => { /** * 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; - const { start, end } = parseTimerange(timerange); - return start !== null && end !== null && start === end; + return startSeconds === endSeconds && startNanos === endNanos; }; 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/Flows/index.jsx b/src/views/Flows/index.jsx index dc734868..c8f899ff 100644 --- a/src/views/Flows/index.jsx +++ b/src/views/Flows/index.jsx @@ -141,9 +141,8 @@ const columnDefinitions = [ // use the tags.init_segment Flow tag instead. cell: (item) => { if (hasInitSegments(item)) return "true"; - return item.essence_parameters?.init_segments === undefined - ? null - : "false"; + // 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)), From 479ac1eb36d19a08dcd4604c1e688bc10e977a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 22:15:04 +0200 Subject: [PATCH 15/16] VI-5068 Complete the capability signal across its remaining consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three consumers were missed when detectionFailed was introduced, and the retry it enabled turned out to undermine the signal itself. useService: enabling shouldRetryOnError made `resolved` oscillate, because SWR sets isLoading true again on each retry while error stays set — so the version cell, the Profiles warning and the sidebar badge pulsed between a spinner and the honest answer, forever, since errorRetryCount defaults to unbounded. An error is a settled answer, so treat it as resolved, and cap the retries at 5 so an unreachable store comes to rest. useStorageBackends dropped its error and its consumer never destructured it, so on an 8.2 store whose storage-backends endpoint fails the tag filter simply was not on the page. It now reports the error inline. The request is also gated on `supported`, having previously fired — and 404'd — on every mount against an 8.0 store. ProfileTab checked neither supported nor resolved, so a Flow with a profile_id rendered an empty metadata panel indistinguishable from a Profile that has none. Embed had the same unresolved-capability double run that d0904fe fixed in the Diagram, but worse: the first pass writes into a permanent closedRef which the second pass then skips, so a source resolved via the degraded path kept the wrong video flow for the whole session. Co-Authored-By: Claude Opus 5 (1M context) --- src/hooks/useService.jsx | 24 ++++++++++++++++++----- src/views/Embed/index.jsx | 8 +++++--- src/views/Flow/components/ProfileTab.jsx | 11 +++++++++-- src/views/Flow/components/SegmentsTab.jsx | 12 ++++++++++-- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/hooks/useService.jsx b/src/hooks/useService.jsx index 10f49cea..bca346fd 100644 --- a/src/hooks/useService.jsx +++ b/src/hooks/useService.jsx @@ -13,8 +13,10 @@ export const useService = () => { 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. + // 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, } ); @@ -43,7 +45,9 @@ export const useCapabilities = () => { service, error, detectionFailed: Boolean(error), - resolved: !isLoading, + // 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), }; }; @@ -54,10 +58,18 @@ export const useCapabilities = () => { */ export const useStorageBackends = () => { const api = useApi(); - const { storageBackendTags: supported, resolved } = useCapabilities(); + 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 ? [api.endpoint, "/service/storage-backends"] : null, + api.endpoint && resolved && supported + ? [api.endpoint, "/service/storage-backends"] + : null, ([, path]) => api.get(path), { revalidateOnFocus: false, shouldRetryOnError: false } ); @@ -82,7 +94,9 @@ export const useStorageBackends = () => { name, values: [...values], })), - supported: supported && resolved, + supported, + resolved, + detectionFailed, isLoading, error, }; diff --git a/src/views/Embed/index.jsx b/src/views/Embed/index.jsx index f6547917..b5a0c0c1 100644 --- a/src/views/Embed/index.jsx +++ b/src/views/Embed/index.jsx @@ -125,7 +125,7 @@ const resolveVideoSourceId = async (source, api, canQueryCollections) => { */ const useSourceFlowDetails = (displayedSources, liveSourceIds) => { const api = useApi(); - const { collectedByIds: canQueryCollections } = useCapabilities(); + const { collectedByIds: canQueryCollections, resolved } = useCapabilities(); const [flowDetails, setFlowDetails] = useState(new Map()); const [isLoading, setIsLoading] = useState(false); @@ -136,7 +136,9 @@ const useSourceFlowDetails = (displayedSources, liveSourceIds) => { // 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 () => { @@ -194,7 +196,7 @@ const useSourceFlowDetails = (displayedSources, liveSourceIds) => { fetchAll(); return () => { cancelled = true; }; - }, [api, displayedSources, liveSourceIds, canQueryCollections]); + }, [api, displayedSources, liveSourceIds, canQueryCollections, resolved]); // Poll growing flows by their flow ID every 5s useEffect(() => { diff --git a/src/views/Flow/components/ProfileTab.jsx b/src/views/Flow/components/ProfileTab.jsx index 2e93c69e..8523bc31 100644 --- a/src/views/Flow/components/ProfileTab.jsx +++ b/src/views/Flow/components/ProfileTab.jsx @@ -8,9 +8,10 @@ import { useProfile } from "@/hooks/useService"; * the technical metadata it applied to this Flow. */ const ProfileTab = ({ profileId }) => { - const { profile, isLoading, error } = useProfile(profileId); + const { profile, supported, resolved, detectionFailed, isLoading, error } = + useProfile(profileId); - if (isLoading) { + if (!resolved || isLoading) { return ( @@ -27,6 +28,12 @@ const ProfileTab = ({ profileId }) => { 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."} + ) : ( )} diff --git a/src/views/Flow/components/SegmentsTab.jsx b/src/views/Flow/components/SegmentsTab.jsx index a752c2d2..9804bce4 100644 --- a/src/views/Flow/components/SegmentsTab.jsx +++ b/src/views/Flow/components/SegmentsTab.jsx @@ -25,7 +25,11 @@ const SegmentsTab = ({ flowId }) => { // 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 } = useStorageBackends(); + const { + tags: storageTags, + supported: supportsStorageTags, + error: storageTagsError, + } = useStorageBackends(); const [tagName, setTagName] = useState(ANY); const [tagValue, setTagValue] = useState(ANY); @@ -115,10 +119,14 @@ const SegmentsTab = ({ flowId }) => { title: "Preferences", }; - return ( Showing last {SEGMENT_COUNT} segments + {supportsStorageTags && storageTagsError && ( + + Storage backend tags unavailable: {storageTagsError.message} + + )} {supportsStorageTags && storageTags.length > 0 && ( From 27ac327543c82381fa28cadec7dd80d24e1cf063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20H=C3=B8ibraaten?= Date: Tue, 18 Aug 2026 22:16:52 +0200 Subject: [PATCH 16/16] VI-5068 Harden preference rehydrate and truncation reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mergeColumns guarded the array but not its elements, and moving it into `merge` meant it now runs on every rehydrate rather than only on a version bump. A stored contentDisplay containing a null element would throw, and zustand swallows a merge throw entirely when no onRehydrateStorage is configured — every preference silently reverts to defaults and the next change re-persists them. Filter malformed entries, and log a rehydrate failure instead of discarding it. Verified that a blob with a null column now keeps pageSize and mode. fetchEntityTree returned depthExceeded that nothing consumed — the same unreachable plumbing 9e7cea4 removed. Dropped, and the remaining flag is named for what it means: the last level was never probed for children, so a tree exactly MAX_DEPTH deep reports "may be incomplete" rather than asserting it is. The truncation note is now gated on treeMode. It was tree-only by accident: paginationFetcher computes a `truncated` it does not return, so one line there would have started appending a hierarchy message to flat listings. paginationFetcher skipped a malformed continuation page while still following its cursor, so a listing missing whole pages reported itself complete — now load-bearing, since callers report completeness to the user. It throws, as the first page already does. Also state in the Flows header that filtering a hierarchy promotes children of non-matching parents to the top level, and drop the unused useSWR import in Embed. Co-Authored-By: Claude Opus 5 (1M context) --- src/stores/usePreferencesStore.jsx | 18 ++++++++++++++---- src/utils/fetchEntityTree.js | 9 +++++---- src/utils/paginationFetcher.js | 7 +++++-- src/views/Embed/index.jsx | 1 - src/views/Flows/index.jsx | 11 +++++++++-- src/views/Sources/index.jsx | 4 ++-- 6 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/stores/usePreferencesStore.jsx b/src/stores/usePreferencesStore.jsx index 4564e556..1be3b5f2 100644 --- a/src/stores/usePreferencesStore.jsx +++ b/src/stores/usePreferencesStore.jsx @@ -126,8 +126,14 @@ const DEFAULT_COLUMNS_BY_KEY = { */ const mergeColumns = (persisted, defaults) => { if (!Array.isArray(persisted)) return defaults; - const known = new Set(persisted.map((column) => column.id)); - return [...persisted, ...defaults.filter((column) => !known.has(column.id))]; + // 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) => { @@ -218,9 +224,13 @@ const usePreferencesStore = create( // 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: without it, zustand discards the whole persisted state of - // anyone whose storage predates `version`. + // 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/fetchEntityTree.js b/src/utils/fetchEntityTree.js index 6c128337..e54189af 100644 --- a/src/utils/fetchEntityTree.js +++ b/src/utils/fetchEntityTree.js @@ -70,14 +70,15 @@ const fetchEntityTree = async (entityType, api, maxResults = TAMS_PAGE_LIMIT) => frontier = fresh.map((item) => item.id); } - // A non-empty frontier here means the depth cap stopped the descent. - const depthExceeded = frontier.length > 0; + // 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: depthExceeded || childrenTruncated, - depthExceeded, + truncated: depthUnverified || childrenTruncated, }; }; diff --git a/src/utils/paginationFetcher.js b/src/utils/paginationFetcher.js index 52dea5c4..c6a5ad97 100644 --- a/src/utils/paginationFetcher.js +++ b/src/utils/paginationFetcher.js @@ -17,9 +17,12 @@ 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; diff --git a/src/views/Embed/index.jsx b/src/views/Embed/index.jsx index b5a0c0c1..6d95fc6f 100644 --- a/src/views/Embed/index.jsx +++ b/src/views/Embed/index.jsx @@ -1,5 +1,4 @@ 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"; diff --git a/src/views/Flows/index.jsx b/src/views/Flows/index.jsx index c8f899ff..da13eece 100644 --- a/src/views/Flows/index.jsx +++ b/src/views/Flows/index.jsx @@ -349,8 +349,15 @@ const Flows = () => { "Status comes from the flow's status attribute; the store applies the status filter and the Created, Metadata updated and Label sorts."; } - if (truncated) { - description = `${description} The hierarchy is incomplete: it is nested deeper than ${MAX_DEPTH} levels, or a collection has more children than one page.`; + // 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) { diff --git a/src/views/Sources/index.jsx b/src/views/Sources/index.jsx index 358ed231..6c1f6bca 100644 --- a/src/views/Sources/index.jsx +++ b/src/views/Sources/index.jsx @@ -245,8 +245,8 @@ const Sources = () => { "Sorted by the store. Sort on Created, Updated or Label to re-query; other columns sort the loaded rows."; } - if (truncated) { - description = `${description} The hierarchy is incomplete: it is nested deeper than ${MAX_DEPTH} levels, or a collection has more children than one page.`; + 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) {