This is an automated email from the ASF dual-hosted git repository.

guoqqqi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new bd1ee4f40 fix: allow colons in label values (#3434)
bd1ee4f40 is described below

commit bd1ee4f40b972b30737d2b027afa60c1aaf90193
Author: Yuhan <[email protected]>
AuthorDate: Mon Jul 20 16:57:52 2026 +0800

    fix: allow colons in label values (#3434)
---
 .../regression/form.labels-colon-value.spec.ts     | 123 +++++++++++++++++++++
 src/components/form/Labels.tsx                     |  28 ++---
 src/components/form/labels-conversion.test.ts      | 101 +++++++++++++++++
 src/components/form/labels-conversion.ts           |  65 +++++++++++
 4 files changed, 301 insertions(+), 16 deletions(-)

diff --git a/e2e/tests/regression/form.labels-colon-value.spec.ts 
b/e2e/tests/regression/form.labels-colon-value.spec.ts
new file mode 100644
index 000000000..23a7a68de
--- /dev/null
+++ b/e2e/tests/regression/form.labels-colon-value.spec.ts
@@ -0,0 +1,123 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Regression for a data-integrity item of apache/apisix-dashboard#3417:
+// the Labels widget split every tag on EVERY colon and required exactly
+// two parts. A label value containing a colon (e.g. a registry address —
+// accepted and stored by the Admin API) could not be typed in, and once
+// stored via the API its mere presence made every subsequent edit of the
+// tag set (adding or removing ANY tag) error out and be discarded — the
+// resource's labels were permanently uneditable from the dashboard.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.beforeAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test('tag set stays editable when a stored label value contains a colon', 
async ({
+  page,
+}) => {
+  const name = randomId('reg-colon-label');
+  const res = await e2eReq.put<{ value: APISIXType['Route'] }>(
+    `/routes/${name}`,
+    {
+      name,
+      uri: `/reg-colon-label/${name}`,
+      labels: { registry: 'docker.io:5000', env: 'prod' },
+      upstream: { type: 'roundrobin', nodes: { 'colon-label.local:80': 1 } },
+    }
+  );
+  const id = res.data.value.id;
+
+  await uiGoto(page, '/routes/detail/$id', { id });
+  await routesPom.isDetailPage(page);
+  // the stored labels display as joined tags
+  await expect(page.getByText('registry:docker.io:5000')).toBeVisible();
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+
+  // adding a tag re-parses the whole set: unfixed, the colon-value
+  // survivor fails the exactly-one-colon check, an error shows, and the
+  // addition is discarded
+  const labelsInput = page.getByLabel('Labels', { exact: true }).first();
+  await labelsInput.fill('team:gateway');
+  await labelsInput.press('Enter');
+  await expect(page.getByText('team:gateway')).toBeVisible();
+
+  await page.getByRole('button', { name: 'Save' }).click();
+  await expect(
+    page.getByRole('alert').filter({ hasText: /success/i })
+  ).toBeVisible();
+
+  const after = await e2eReq.get<{ value: APISIXType['Route'] }>(
+    `/routes/${id}`
+  );
+  expect(after.data.value.labels).toEqual({
+    registry: 'docker.io:5000',
+    env: 'prod',
+    team: 'gateway',
+  });
+});
+
+test('a new label whose value contains a colon can be typed in', async ({
+  page,
+}) => {
+  const name = randomId('reg-colon-label-input');
+  const res = await e2eReq.put<{ value: APISIXType['Route'] }>(
+    `/routes/${name}`,
+    {
+      name,
+      uri: `/reg-colon-label-input/${name}`,
+      upstream: { type: 'roundrobin', nodes: { 'colon-label.local:80': 1 } },
+    }
+  );
+  const id = res.data.value.id;
+
+  await uiGoto(page, '/routes/detail/$id', { id });
+  await routesPom.isDetailPage(page);
+  await page.getByRole('button', { name: 'Edit' }).click();
+
+  const labelsInput = page.getByLabel('Labels', { exact: true }).first();
+  await labelsInput.fill('mirror:registry.local:5443');
+  await labelsInput.press('Enter');
+  await expect(page.getByText('mirror:registry.local:5443')).toBeVisible();
+
+  await page.getByRole('button', { name: 'Save' }).click();
+  await expect(
+    page.getByRole('alert').filter({ hasText: /success/i })
+  ).toBeVisible();
+
+  const after = await e2eReq.get<{ value: APISIXType['Route'] }>(
+    `/routes/${id}`
+  );
+  expect(after.data.value.labels).toEqual({
+    mirror: 'registry.local:5443',
+  });
+});
diff --git a/src/components/form/Labels.tsx b/src/components/form/Labels.tsx
index 22568fa5a..3c30a8aae 100644
--- a/src/components/form/Labels.tsx
+++ b/src/components/form/Labels.tsx
@@ -25,6 +25,11 @@ import { useTranslation } from 'react-i18next';
 
 import type { APISIXType } from '@/types/schema/apisix';
 
+import {
+  labelsToTags,
+  parseLabelTag,
+  tagsToLabels,
+} from './labels-conversion';
 import { genControllerProps } from './util';
 
 export type FormItemLabels<T extends FieldValues> = UseControllerProps<T> &
@@ -44,17 +49,12 @@ export const FormItemLabels = <T extends FieldValues>(
   const { t } = useTranslation();
   const [internalError, setInternalError] = useState<string | null>();
 
-  const values = useMemo(() => {
-    // Defensive: ensure value is a plain object (not array or null)
-    if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
-    return Object.entries(value).map(([key, val]) => `${key}:${val}`);
-  }, [value]);
+  const values = useMemo(() => labelsToTags(value), [value]);
 
   const handleSearchChange = useCallback(
     (val: string) => {
-      const tuple = val.split(':');
       // when clear input, val can be ''
-      if (val && tuple.length !== 2) {
+      if (val && !parseLabelTag(val)) {
         setInternalError(t('form.basic.labels.errorFormat'));
         return;
       }
@@ -65,20 +65,16 @@ export const FormItemLabels = <T extends FieldValues>(
 
   const handleChange = useCallback(
     (vals: string[]) => {
-      const obj: APISIXType['Labels'] = {};
-      for (const val of vals) {
-        const tuple = val.split(':');
-        if (tuple.length !== 2) {
-          setInternalError(t('form.basic.labels.errorFormat'));
-          return;
-        }
-        obj[tuple[0]] = tuple[1];
+      const obj = tagsToLabels(vals, value);
+      if (!obj) {
+        setInternalError(t('form.basic.labels.errorFormat'));
+        return;
       }
       setInternalError(null);
       fOnChange(obj);
       restProps.onChange?.(obj);
     },
-    [fOnChange, restProps, t]
+    [fOnChange, restProps, t, value]
   );
 
   return (
diff --git a/src/components/form/labels-conversion.test.ts 
b/src/components/form/labels-conversion.test.ts
new file mode 100644
index 000000000..69a4d76b3
--- /dev/null
+++ b/src/components/form/labels-conversion.test.ts
@@ -0,0 +1,101 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { describe, expect, it } from 'vitest';
+
+import {
+  labelsToTags,
+  parseLabelTag,
+  tagsToLabels,
+} from './labels-conversion';
+
+// Regression for a data-integrity item of #3417: the Labels widget split
+// every tag on EVERY colon and required exactly two parts, so a label
+// value containing a colon (registry addresses, times, ratios — accepted
+// and stored by the Admin API, verified live) could not be typed in, and
+// its mere presence made every subsequent edit of the tag set error out
+// and be discarded. New-input parsing splits on the FIRST colon; strings
+// that match an existing entry's joined form keep that entry verbatim, so
+// stored labels (including colon-in-KEY ones, which the API also accepts)
+// are never silently rewritten by edits to other tags.
+
+describe('parseLabelTag', () => {
+  it('splits on the first colon only', () => {
+    expect(parseLabelTag('registry:docker.io:5000')).toEqual([
+      'registry',
+      'docker.io:5000',
+    ]);
+  });
+
+  it('accepts a plain key:value pair', () => {
+    expect(parseLabelTag('env:prod')).toEqual(['env', 'prod']);
+  });
+
+  it('rejects a tag without a colon', () => {
+    expect(parseLabelTag('env')).toBeNull();
+  });
+
+  it('rejects empty key or value (the Admin API rejects them with 400)', () => 
{
+    expect(parseLabelTag(':prod')).toBeNull();
+    expect(parseLabelTag('env:')).toBeNull();
+  });
+});
+
+describe('tagsToLabels', () => {
+  it('parses new colon-value input by first colon', () => {
+    expect(tagsToLabels(['registry:docker.io:5000'], {})).toEqual({
+      registry: 'docker.io:5000',
+    });
+  });
+
+  it('keeps existing entries verbatim when other tags change', () => {
+    const current = { 'a:b': 'c', env: 'prod' };
+    expect(tagsToLabels(['a:b:c', 'env:prod', 'team:gw'], current)).toEqual({
+      'a:b': 'c',
+      env: 'prod',
+      team: 'gw',
+    });
+  });
+
+  it('supports removing a tag without disturbing colon-key survivors', () => {
+    const current = { 'a:b': 'c', env: 'prod' };
+    expect(tagsToLabels(['a:b:c'], current)).toEqual({ 'a:b': 'c' });
+  });
+
+  it('returns null for invalid new input', () => {
+    expect(tagsToLabels(['nocolon'], {})).toBeNull();
+    expect(tagsToLabels(['env:'], {})).toBeNull();
+  });
+
+  it('tolerates a non-object current value', () => {
+    expect(tagsToLabels(['env:prod'], undefined)).toEqual({ env: 'prod' });
+    expect(tagsToLabels(['env:prod'], ['array'])).toEqual({ env: 'prod' });
+  });
+});
+
+describe('labelsToTags', () => {
+  it('joins entries with a colon', () => {
+    expect(labelsToTags({ registry: 'docker.io:5000' })).toEqual([
+      'registry:docker.io:5000',
+    ]);
+  });
+
+  it('returns [] for non-object values', () => {
+    expect(labelsToTags(undefined)).toEqual([]);
+    expect(labelsToTags(['x'])).toEqual([]);
+    expect(labelsToTags(null)).toEqual([]);
+  });
+});
diff --git a/src/components/form/labels-conversion.ts 
b/src/components/form/labels-conversion.ts
new file mode 100644
index 000000000..5e437fced
--- /dev/null
+++ b/src/components/form/labels-conversion.ts
@@ -0,0 +1,65 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import type { APISIXType } from '@/types/schema/apisix';
+
+export const labelsToTags = (value: unknown): string[] => {
+  // Defensive: ensure value is a plain object (not array or null)
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
+  return Object.entries(value).map(([key, val]) => `${key}:${val}`);
+};
+
+/**
+ * Parse NEW user input on the FIRST colon: keys cannot contain colons via
+ * the dashboard, values can (registry addresses, times, ratios — the
+ * Admin API accepts and stores them). Key and value must both be
+ * non-empty: the Admin API rejects empty ones with 400.
+ */
+export const parseLabelTag = (tag: string): [string, string] | null => {
+  const i = tag.indexOf(':');
+  if (i <= 0 || i === tag.length - 1) return null;
+  return [tag.slice(0, i), tag.slice(i + 1)];
+};
+
+/**
+ * Convert the TagsInput strings back into a labels object. A string that
+ * matches an existing entry's joined `key:value` form keeps that entry
+ * VERBATIM — editing other tags can never rewrite a stored label (the
+ * Admin API also accepts colon-in-KEY labels, which a re-parse would
+ * silently turn into a different pair). Only genuinely new strings are
+ * parsed. Returns null when a new string is not a valid `key:value`.
+ */
+export const tagsToLabels = (
+  tags: string[],
+  current: unknown
+): APISIXType['Labels'] | null => {
+  const existing = new Map<string, [string, string]>();
+  if (current && typeof current === 'object' && !Array.isArray(current)) {
+    for (const [key, val] of Object.entries(
+      current as Record<string, unknown>
+    )) {
+      const joined = `${key}:${val}`;
+      if (!existing.has(joined)) existing.set(joined, [key, String(val)]);
+    }
+  }
+  const obj: APISIXType['Labels'] = {};
+  for (const tag of tags) {
+    const pair = existing.get(tag) ?? parseLabelTag(tag);
+    if (!pair) return null;
+    obj[pair[0]] = pair[1];
+  }
+  return obj;
+};

Reply via email to