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;
+};