Skip to content

UI: normalize serialized no-status child_states key ("None") in collapsed-aggregate rendering #67541

@nathadfield

Description

@nathadfield

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:

  1. 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.
  2. 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

  1. Save the DAG above as none_child_state_demo.py in your dags folder.
  2. Trigger the DAG manually.
  3. While make_args is still running (≤60s window), open the grid view and locate the consume mapped task.
  4. 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.
  5. 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?

  • Yes I am willing to submit a PR!

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:UIRelated to UI/UX. For Frontend Developers.kind:bugThis is a clearly a bug

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions