Under which category would you file this issue?
Airflow Core
Apache Airflow version
main (development)
What happened and how to reproduce it?
Issue Description
The grid API endpoint serializes Python None task-instance states as the JSON dict key "None" (Pydantic conversion of dict[TaskInstanceState | None, int] in LightGridTaskInstanceSummary.child_states). Two UI surfaces introduced by #61854 iterate over child_states directly and render the raw "None" key, producing tokenless / untranslated output:
airflow-core/src/airflow/ui/src/components/Graph/SegmentedStateBar.tsx:44 — <Box bg={${state}.solid}> per entry; "None.solid" does not resolve to a Chakra theme token, so the no-status slice renders without a colour.
airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx:143-156 — per-state breakdown row uses the same bg={${state}.solid} swatch (tokenless) and translate(common:states.${state}) (no translation for "None", and no .toLowerCase() applied here unlike the Header sites), so the row shows a colourless swatch beside the literal key common:states.None.
The Group / Mapped Header stats sites (pages/GroupTaskInstance/Header.tsx, pages/MappedTaskInstance/Header.tsx) are not affected — they call taskState.toLowerCase() before translating, so "None" → "none" → common:states.none ("No Status"), which already exists in airflow-core/src/airflow/ui/public/i18n/locales/en/common.json:231.
Minimal reproducer
import time
from datetime import datetime
from airflow.sdk import DAG, task
with DAG(
dag_id="none_child_state_demo",
description="Demonstrates frontend rendering bug for serialized no-status child_states key",
start_date=datetime(2024, 1, 1),
schedule=None,
catchup=False,
) as dag:
@task
def make_args():
# Sleep so the mapped consumer stays unexpanded long enough to
# observe the bug in the UI.
time.sleep(60)
return ["a", "b", "c"]
@task
def consume(item):
return item
# Until `make_args` returns, `consume` has no rows and the grid API
# reports child_states={"None": 1} for it.
consume.expand(item=make_args())
Steps to reproduce
- Save the DAG above as
none_child_state_demo.py in your dags folder.
- Trigger the DAG manually.
- While
make_args is still running (≤60s window), open the grid view and locate the consume mapped task.
- Observe:
- Segmented state bar (graph view, collapsed group containing
consume): the no-status slice renders without a colour token.
- Tooltip on the
consume cell: breakdown row shows 1 common:states.None with no colour swatch.
- Confirm the API payload by querying
/grid/ti_summaries/none_child_state_demo?run_ids=<run_id> during the same window — the consume entry will include "child_states": {"None": 1}. The wire format is also asserted in airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py:839,848.
What you think should happen instead?
Each render site should normalize the serialized "None" key to the existing "none" / "no_status" UI convention so the no-status slice shows the same neutral colour and the "No status" translated label that the rest of the UI uses for tasks without a state.
Suggested approach: add a single helper (e.g. normalizeStateKey(key: string): string) to airflow-core/src/airflow/ui/src/utils/stateUtils.ts that maps "None" → "none", and call it at the two affected render sites:
- For
bg={${state}.solid} → use normalizeStateKey(state) (mirrors the existing pattern at airflow-core/src/airflow/ui/src/pages/Dashboard/HistoricalMetrics/MetricSection.tsx:79, which already does state === "no_status" ? "none" : state).
- For
translate(common:states.${state}) → map "None" to "no_status" so it picks up the existing localized label.
Acceptance criteria
- Group with
child_states={"None": 2, "success": 1} shows a coloured and labelled no-status slice in the segmented bar.
- Tooltip breakdown row for
"None": N shows the localized "No status" label beside a visible colour swatch.
- Existing labels (
success, running, etc.) and colours unchanged.
- Add unit coverage for the helper and at least one render site (suggested: the tooltip breakdown, since it's the cheapest surface to assert on).
Operating System
Not Applicable (frontend-only)
Deployment
Other
Apache Airflow Provider(s)
None — not provider-related.
Versions of Apache Airflow Providers
Not Applicable
Official Helm Chart version
Not Applicable
Kubernetes Version
Not Applicable
Helm Chart configuration
Not Applicable
Docker Image customizations
Not Applicable
Anything else?
This is a deferred follow-up identified during review of work to wire up getDisplayState — a helper that fixes the dominant-state colouring of collapsed groups / mapped tasks across the badge / border / MiniMap / state filter, all of which correctly fall back to null when "None" is the dominant child key. That work deliberately scopes itself to the dominant-state surfaces; the two breakdown / segmented-bar sites listed above iterate child_states directly and remain on the pre-existing rendering path. The fix is small but cross-cuts two UI files and a new helper + tests — clean isolated scope for a separate PR.
Original feature PR: #61854.
Drafted-by: Claude Code (Opus 4.7); reviewed by @nathadfield before posting
Are you willing to submit PR?
Code of Conduct
Under which category would you file this issue?
Airflow Core
Apache Airflow version
main (development)
What happened and how to reproduce it?
Issue Description
The grid API endpoint serializes Python
Nonetask-instance states as the JSON dict key"None"(Pydantic conversion ofdict[TaskInstanceState | None, int]inLightGridTaskInstanceSummary.child_states). Two UI surfaces introduced by #61854 iterate overchild_statesdirectly and render the raw"None"key, producing tokenless / untranslated output:airflow-core/src/airflow/ui/src/components/Graph/SegmentedStateBar.tsx:44—<Box bg={${state}.solid}>per entry;"None.solid"does not resolve to a Chakra theme token, so the no-status slice renders without a colour.airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx:143-156— per-state breakdown row uses the samebg={${state}.solid}swatch (tokenless) andtranslate(common:states.${state})(no translation for"None", and no.toLowerCase()applied here unlike the Header sites), so the row shows a colourless swatch beside the literal keycommon:states.None.The Group / Mapped Header stats sites (
pages/GroupTaskInstance/Header.tsx,pages/MappedTaskInstance/Header.tsx) are not affected — they calltaskState.toLowerCase()before translating, so"None"→"none"→common:states.none("No Status"), which already exists inairflow-core/src/airflow/ui/public/i18n/locales/en/common.json:231.Minimal reproducer
Steps to reproduce
none_child_state_demo.pyin yourdagsfolder.make_argsis still running (≤60s window), open the grid view and locate theconsumemapped task.consume): the no-status slice renders without a colour token.consumecell: breakdown row shows1 common:states.Nonewith no colour swatch./grid/ti_summaries/none_child_state_demo?run_ids=<run_id>during the same window — theconsumeentry will include"child_states": {"None": 1}. The wire format is also asserted inairflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py:839,848.What you think should happen instead?
Each render site should normalize the serialized
"None"key to the existing"none"/"no_status"UI convention so the no-status slice shows the same neutral colour and the "No status" translated label that the rest of the UI uses for tasks without a state.Suggested approach: add a single helper (e.g.
normalizeStateKey(key: string): string) toairflow-core/src/airflow/ui/src/utils/stateUtils.tsthat maps"None"→"none", and call it at the two affected render sites:bg={${state}.solid}→ usenormalizeStateKey(state)(mirrors the existing pattern atairflow-core/src/airflow/ui/src/pages/Dashboard/HistoricalMetrics/MetricSection.tsx:79, which already doesstate === "no_status" ? "none" : state).translate(common:states.${state})→ map"None"to"no_status"so it picks up the existing localized label.Acceptance criteria
child_states={"None": 2, "success": 1}shows a coloured and labelled no-status slice in the segmented bar."None": Nshows the localized "No status" label beside a visible colour swatch.success,running, etc.) and colours unchanged.Operating System
Not Applicable (frontend-only)
Deployment
Other
Apache Airflow Provider(s)
None — not provider-related.
Versions of Apache Airflow Providers
Not Applicable
Official Helm Chart version
Not Applicable
Kubernetes Version
Not Applicable
Helm Chart configuration
Not Applicable
Docker Image customizations
Not Applicable
Anything else?
This is a deferred follow-up identified during review of work to wire up
getDisplayState— a helper that fixes the dominant-state colouring of collapsed groups / mapped tasks across the badge / border / MiniMap / state filter, all of which correctly fall back tonullwhen"None"is the dominant child key. That work deliberately scopes itself to the dominant-state surfaces; the two breakdown / segmented-bar sites listed above iteratechild_statesdirectly and remain on the pre-existing rendering path. The fix is small but cross-cuts two UI files and a new helper + tests — clean isolated scope for a separate PR.Original feature PR: #61854.
Drafted-by: Claude Code (Opus 4.7); reviewed by @nathadfield before posting
Are you willing to submit PR?
Code of Conduct