Similarityoung commented on code in PR #1046: URL: https://github.com/apache/dubbo-go-pixiu/pull/1046#discussion_r4072232394
########## admin/controller/configInfo/route_binding.go: ########## @@ -0,0 +1,471 @@ +/* + * 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. + */ + +package configInfo + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +import ( + "github.com/gin-gonic/gin" +) + +import ( + adminconfig "github.com/apache/dubbo-go-pixiu/admin/config" + "github.com/apache/dubbo-go-pixiu/admin/logic" + "github.com/apache/dubbo-go-pixiu/pkg/config/schema" +) + +const maxRouteBindingRequestBytes = 4 << 20 + +type routeBindingRequest struct { + Object *schema.AdminObject `json:"object"` + ExpectedRevision int64 `json:"expectedRevision"` +} + +type routeBindingValidationResponse struct { + Valid bool `json:"valid"` + Object schema.AdminObject `json:"object"` +} + +type routeBindingPreviewResponse struct { + Object schema.AdminObject `json:"object"` + YAML string `json:"yaml"` +} + +type routeBindingErrorResponse struct { + Message string `json:"message"` + Issues []schema.ValidationIssue `json:"issues,omitempty"` +} + +// GetRouteBindingSchema returns the AdminRouteBinding form and validation +// schema. It has no etcd dependency so the UI can render before a config +// namespace contains any routes. +// +// @Tags Config +// @Summary get API route binding schema +// @Produce application/json +// @Success 200 {object} string +// @Router /config/api/route/schema [get] +func GetRouteBindingSchema(c *gin.Context) { + registry, err := schema.NewBuiltinRegistry() + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(registry.List())) +} + +// GetRouteBindingList returns high-level route bindings from the draft or +// published namespace. The default scope is draft; pass scope=published or +// unpublished=0 to read the published snapshot. +// +// @Tags Config +// @Summary get API route binding list +// @Produce application/json +// @Param scope query string false "draft or published" +// @Param unpublished query string false "1 for draft, 0 for published" +// @Success 200 {object} string +// @Router /config/api/route/list [get] +func GetRouteBindingList(c *gin.Context) { + store, err := logic.NewAdminRouteBindingStore() + if err != nil { + writeRouteBindingError(c, err) + return + } + bindings, err := store.List(c.Request.Context(), routeBindingUnpublished(c)) + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(bindings)) +} + +// GetRouteBindingDetail returns one high-level route binding by metadata.name. +// +// @Tags Config +// @Summary get API route binding detail +// @Produce application/json +// @Param name query string true "Route binding name" +// @Param scope query string false "draft or published" +// @Param unpublished query string false "1 for draft, 0 for published" +// @Success 200 {object} string +// @Router /config/api/route/detail [get] +func GetRouteBindingDetail(c *gin.Context) { + name := c.Query("name") + if name == "" { + name = c.Param("name") + } + store, err := logic.NewAdminRouteBindingStore() + if err != nil { + writeRouteBindingError(c, err) + return + } + binding, err := store.Get(c.Request.Context(), name, routeBindingUnpublished(c)) + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(binding)) +} + +// CreateRouteBinding validates and saves one route binding in the draft +// namespace. The JSON body may be either an AdminObject or +// {"object": AdminObject, "expectedRevision": number}; a form content field +// containing the same object as YAML is also accepted for Admin compatibility. +// +// @Tags Config +// @Summary create API route binding draft +// @Accept application/json +// @Produce application/json +// @Success 200 {object} string +// @Router /config/api/route [post] +func CreateRouteBinding(c *gin.Context) { + object, expectedRevision, err := decodeRouteBindingRequest(c) + if err != nil { + writeRouteBindingError(c, err) + return + } + store, err := logic.NewAdminRouteBindingStore() + if err != nil { + writeRouteBindingError(c, err) + return + } + binding, err := store.SaveDraft(c.Request.Context(), object, true, expectedRevision) + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(binding)) +} + +// ModifyRouteBinding validates and replaces one route binding draft. If the +// draft does not exist but a published binding with the same name exists, its +// stable runtime identity is reused. +// +// @Tags Config +// @Summary modify API route binding draft +// @Accept application/json +// @Produce application/json +// @Success 200 {object} string +// @Router /config/api/route [put] +func ModifyRouteBinding(c *gin.Context) { + object, expectedRevision, err := decodeRouteBindingRequest(c) + if err != nil { + writeRouteBindingError(c, err) + return + } + store, err := logic.NewAdminRouteBindingStore() + if err != nil { + writeRouteBindingError(c, err) + return + } + binding, err := store.SaveDraft(c.Request.Context(), object, false, expectedRevision) + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(binding)) +} + +// DeleteRouteBinding removes one route draft and its published runtime state +// in one atomic transaction after the caller confirms the deletion. +// +// @Tags Config +// @Summary delete and publish one API route binding +// @Produce application/json +// @Param name query string true "Route binding name" +// @Param expectedRevision query int false "Draft key mod revision" +// @Success 200 {object} string +// @Router /config/api/route [delete] +func DeleteRouteBinding(c *gin.Context) { + name := c.Query("name") + expectedRevision, err := routeBindingExpectedRevision(c) + if err != nil { + writeRouteBindingError(c, err) + return + } + store, err := logic.NewAdminRouteBindingStore() + if err != nil { + writeRouteBindingError(c, err) + return + } + result, err := store.DeleteAndPublish(c.Request.Context(), name, expectedRevision) + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(result)) +} + +// ValidateRouteBinding validates an object and returns schema defaults without +// writing anything to etcd. +// +// @Tags Config +// @Summary validate API route binding +// @Accept application/json +// @Produce application/json +// @Success 200 {object} string +// @Router /config/api/route/validate [post] +func ValidateRouteBinding(c *gin.Context) { + object, _, err := decodeRouteBindingRequest(c) + if err != nil { + writeRouteBindingError(c, err) + return + } + registry, err := schema.NewBuiltinRegistry() + if err != nil { + writeRouteBindingError(c, err) + return + } + normalized, err := registry.Normalize(object) + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(routeBindingValidationResponse{Valid: true, Object: normalized})) +} + +// PreviewRouteBinding validates and compiles an object to the legacy +// api_config.yaml shape without writing anything to etcd. +// +// @Tags Config +// @Summary preview API route binding +// @Accept application/json +// @Produce application/json +// @Success 200 {object} string +// @Router /config/api/route/preview [post] +func PreviewRouteBinding(c *gin.Context) { + object, _, err := decodeRouteBindingRequest(c) + if err != nil { + writeRouteBindingError(c, err) + return + } + registry, err := schema.NewBuiltinRegistry() + if err != nil { + writeRouteBindingError(c, err) + return + } + normalized, err := registry.Normalize(object) + if err != nil { + writeRouteBindingError(c, err) + return + } + compiled, err := schema.CompileAdminRouteBinding(registry, normalized) + if err != nil { + writeRouteBindingError(c, err) + return + } + preview, err := compiled.PreviewYAML() + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(routeBindingPreviewResponse{Object: normalized, YAML: string(preview)})) +} + +// PublishRouteBinding publishes one route draft in one etcd transaction. Pass +// name and expectedRevision to reject a stale UI snapshot. +// +// @Tags Config +// @Summary atomically publish one API route binding +// @Produce application/json +// @Param name query string true "Route binding name" +// @Param expectedRevision query int false "Draft route key mod revision" +// @Success 200 {object} string +// @Router /config/api/route/publish [put] +func PublishRouteBinding(c *gin.Context) { + name := c.Query("name") + if name == "" { + name = c.Param("name") + } + if strings.TrimSpace(name) == "" { + writeRouteBindingError(c, errors.New("route binding name is required for publish")) + return + } + expectedRevision, err := routeBindingExpectedRevision(c) + if err != nil { + writeRouteBindingError(c, err) + return + } + store, err := logic.NewAdminRouteBindingStore() + if err != nil { + writeRouteBindingError(c, err) + return + } + result, err := store.Publish(c.Request.Context(), name, expectedRevision) + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(result)) +} + +// GetRouteBindingStatus returns the independent draft and published +// revisions for one route binding. +// +// @Tags Config +// @Summary get API route binding status +// @Produce application/json +// @Param name query string true "Route binding name" +// @Success 200 {object} string +// @Router /config/api/route/status [get] +func GetRouteBindingStatus(c *gin.Context) { + name := c.Query("name") + if name == "" { + name = c.Param("name") + } + if strings.TrimSpace(name) == "" { + writeRouteBindingError(c, errors.New("route binding name is required for status")) + return + } + store, err := logic.NewAdminRouteBindingStore() + if err != nil { + writeRouteBindingError(c, err) + return + } + status, err := store.Status(c.Request.Context(), name) + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(status)) +} + +// GetRouteBindingDiff returns the field-level diff between one route's draft +// and published objects. +// +// @Tags Config +// @Summary get API route binding diff +// @Produce application/json +// @Param name query string true "Route binding name" +// @Success 200 {object} string +// @Router /config/api/route/diff [get] +func GetRouteBindingDiff(c *gin.Context) { + name := c.Query("name") + if name == "" { + name = c.Param("name") + } + if strings.TrimSpace(name) == "" { + writeRouteBindingError(c, errors.New("route binding name is required for diff")) + return + } + store, err := logic.NewAdminRouteBindingStore() + if err != nil { + writeRouteBindingError(c, err) + return + } + diff, err := store.Diff(c.Request.Context(), name) + if err != nil { + writeRouteBindingError(c, err) + return + } + c.JSON(http.StatusOK, adminconfig.WithRet(diff)) +} + +func decodeRouteBindingRequest(c *gin.Context) (schema.AdminObject, int64, error) { + if content := c.PostForm("content"); strings.TrimSpace(content) != "" { + object, err := schema.DecodeAdminObjectYAML([]byte(content)) + if err != nil { + return schema.AdminObject{}, 0, err + } + expectedRevision, err := routeBindingExpectedRevision(c) + return object, expectedRevision, err + } + + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxRouteBindingRequestBytes+1)) + if err != nil { + return schema.AdminObject{}, 0, fmt.Errorf("read route binding request: %w", err) + } + if len(body) > maxRouteBindingRequestBytes { + return schema.AdminObject{}, 0, fmt.Errorf("route binding request exceeds %d bytes", maxRouteBindingRequestBytes) + } + body = []byte(strings.TrimSpace(string(body))) + if len(body) == 0 { + return schema.AdminObject{}, 0, errors.New("route binding request body is empty") + } + + var request routeBindingRequest + if err := json.Unmarshal(body, &request); err == nil && request.Object != nil { + expectedRevision := request.ExpectedRevision + if expectedRevision == 0 { + expectedRevision, err = routeBindingExpectedRevision(c) + } + return *request.Object, expectedRevision, err + } + + var object schema.AdminObject + if err := json.Unmarshal(body, &object); err == nil && (object.Kind != "" || object.Metadata.Name != "" || object.Spec != nil) { + expectedRevision, revisionErr := routeBindingExpectedRevision(c) + return object, expectedRevision, revisionErr + } + + object, err = schema.DecodeAdminObjectYAML(body) + if err != nil { + return schema.AdminObject{}, 0, fmt.Errorf("decode route binding request: %w", err) + } + expectedRevision, revisionErr := routeBindingExpectedRevision(c) + return object, expectedRevision, revisionErr +} + +func routeBindingExpectedRevision(c *gin.Context) (int64, error) { + value := strings.TrimSpace(c.Query("expectedRevision")) + if value == "" { + value = strings.Trim(strings.TrimSpace(c.GetHeader("If-Match")), `"`) + } + if value == "" { + return 0, nil + } + revision, err := strconv.ParseInt(value, 10, 64) + if err != nil || revision < 0 { + return 0, fmt.Errorf("expected revision %q is invalid", value) + } + return revision, nil +} + +func routeBindingUnpublished(c *gin.Context) bool { + if strings.EqualFold(strings.TrimSpace(c.Query("scope")), "published") { + return false + } + if strings.EqualFold(strings.TrimSpace(c.Query("scope")), "draft") { + return true + } + return c.Query("unpublished") != "0" +} + +func writeRouteBindingError(c *gin.Context, err error) { + if err == nil { + err = errors.New("unknown route binding error") + } + var validationErrors schema.ValidationErrors + if errors.As(err, &validationErrors) { + issues := append([]schema.ValidationIssue(nil), validationErrors...) + c.JSON(http.StatusOK, adminconfig.RetData{ + Code: adminconfig.ERR, + Data: routeBindingErrorResponse{Message: err.Error(), Issues: issues}, + }) + return + } + c.JSON(http.StatusOK, adminconfig.WithError(err)) Review Comment: [P2] Preserve the retry-specific response code for revision conflicts ErrRouteBindingConflict and ErrRouteBindingPublishConflict can reach this path, but adminconfig.WithError(err) always emits ERR (10002). The frontend maps CONCURRENT (10003) to the refresh/retry behavior, so stale expectedRevision and failed publish CAS operations are reported as generic errors instead. Please map these conflict errors to adminconfig.RETRY (10003), or return a retry-specific response here. ########## admin/web/src/features/config/RouteBindingEditorSections.tsx: ########## @@ -0,0 +1,1153 @@ +/* + * 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 { + AlertCircle, + ArrowLeft, + CheckCircle2, + Eye, + FileCode2, + GitCompareArrows, + Plus, + Save, + Send, + Trash2, +} from 'lucide-react' +import { translateText } from '../../i18n' +import type { + AdminRouteBindingObject, + RouteBindingDiff, + RouteBindingParam, + RouteBindingPublishStatus, +} from '../../types/api' + +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] +export const PARAM_TYPES = [ + 'string', + 'char', + 'short', + 'int', + 'long', + 'float', + 'double', + 'boolean', + 'byte', + 'date', + 'object', + 'java.lang.String', + 'java.lang.Character', + 'java.lang.Short', + 'java.lang.Integer', + 'java.lang.Long', + 'java.lang.Float', + 'java.lang.Double', + 'java.lang.Boolean', + 'java.lang.Byte', + 'java.lang.Object', + 'java.util.Date', +] + +export type EditorTab = 'form' | 'preview' | 'diff' | 'yaml' +export type BusyAction = '' | 'save' | 'publish' | 'validate' | 'preview' | 'diff' +export type Notice = { tone: 'success' | 'error'; text: string } +export type IssueMessage = (path: string) => string +export type RouteEntry = AdminRouteBindingObject['spec']['entry'] +export type RouteTarget = AdminRouteBindingObject['spec']['target'] +type EditableTargetKey = Exclude<keyof RouteTarget, 'protocol'> + +type HeaderProps = { + readonly isEnglish: boolean + readonly routeLabel: string + readonly entry: RouteEntry + readonly publishStatus: RouteBindingPublishStatus | null + readonly dirty: boolean + readonly published: boolean + readonly enabled: boolean + readonly currentStatus: string + readonly lifecycleStatus: string + readonly busy: BusyAction + readonly yamlError: string + readonly onBack: () => void + readonly onValidate: () => void + readonly onSave: () => void + readonly onPublish: () => void +} + +type TabsProps = { + readonly isEnglish: boolean + readonly activeTab: EditorTab + readonly onChange: (tab: EditorTab) => void +} + +type IdentityCardProps = { + readonly isEnglish: boolean + readonly mode: 'create' | 'edit' + readonly entry: RouteEntry + readonly name: string + readonly issueMessage: IssueMessage + readonly onNameChange: (value: string) => void + readonly onPathChange: (value: string) => void + readonly onMethodChange: (value: string) => void +} + +type TargetCardProps = { + readonly isEnglish: boolean + readonly target: RouteTarget + readonly issueMessage: IssueMessage + readonly onChange: (key: EditableTargetKey, value: string) => void +} + +type ParamCardProps = { + readonly isEnglish: boolean + readonly params: RouteBindingParam[] + readonly issueMessage: IssueMessage + readonly onAdd: () => void + readonly onUpdate: (index: number, patch: Partial<RouteBindingParam>) => void + readonly onRemove: (index: number) => void +} + +type PublishCardProps = { + readonly isEnglish: boolean + readonly enabled: boolean + readonly validate: boolean + readonly onEnabledChange: (enabled: boolean) => void + readonly onValidateChange: (validate: boolean) => void +} + +type FormViewProps = IdentityCardProps & TargetCardProps & ParamCardProps & PublishCardProps + +type DiffViewProps = { + readonly isEnglish: boolean + readonly diffData: RouteBindingDiff | null + readonly busy: BusyAction + readonly onRefresh: () => void +} + +type PreviewViewProps = { + readonly isEnglish: boolean + readonly activeTab: 'preview' | 'yaml' + readonly busy: BusyAction + readonly previewYaml: string + readonly routeYaml: string + readonly yamlError: string + readonly onApplyYaml: () => void + readonly onChangeYaml: (value: string) => void + readonly onPreview: () => void +} + +type ContentProps = { + readonly isEnglish: boolean + readonly loading: boolean + readonly activeTab: EditorTab + readonly mode: 'create' | 'edit' + readonly object: AdminRouteBindingObject + readonly busy: BusyAction + readonly diffData: RouteBindingDiff | null + readonly previewYaml: string + readonly routeYaml: string + readonly yamlError: string + readonly issueMessage: IssueMessage + readonly onNameChange: (value: string) => void + readonly onEntryChange: (key: 'path' | 'method', value: string) => void + readonly onTargetChange: (key: EditableTargetKey, value: string) => void + readonly onParamAdd: () => void + readonly onParamUpdate: (index: number, patch: Partial<RouteBindingParam>) => void + readonly onParamRemove: (index: number) => void + readonly onEnabledChange: (enabled: boolean) => void + readonly onValidateChange: (validate: boolean) => void + readonly onRefreshDiff: () => void + readonly onApplyYaml: () => void + readonly onChangeYaml: (value: string) => void + readonly onPreview: () => void +} + +function actionLabel( + isEnglish: boolean, + busy: BusyAction, + action: Exclude<BusyAction, ''>, + idleLabel: string, +) { + if (busy !== action) return idleLabel + const labels: Record<Exclude<BusyAction, ''>, [string, string]> = { + save: ['Saving...', '保存中...'], + publish: ['Publishing...', '发布中...'], + validate: ['Checking...', '校验中...'], + preview: ['Generating...', '生成中...'], + diff: ['Loading...', '加载中...'], + } + return isEnglish ? labels[action][0] : labels[action][1] +} + +function routeNameHelp(isEnglish: boolean, mode: 'create' | 'edit') { + if (mode === 'edit') + return isEnglish ? 'The key stays stable while editing.' : '编辑时保持路由键稳定。' + return isEnglish + ? 'Used as the AdminRouteBinding metadata.name.' + : '对应 AdminRouteBinding 的 metadata.name。' +} + +function previewTitle(isEnglish: boolean, activeTab: 'preview' | 'yaml') { + if (activeTab === 'yaml') return 'YAML' + return isEnglish ? 'Runtime preview' : '运行时预览' +} + +function previewDescription(isEnglish: boolean, activeTab: 'preview' | 'yaml') { + if (activeTab === 'yaml') { + return isEnglish + ? 'Edit the AdminRouteBinding source. Valid changes sync back to the form.' + : '编辑 AdminRouteBinding 源配置,语法有效的改动会同步回表单。' + } + return isEnglish + ? 'Generated by the current AdminRouteBinding compiler.' + : '由当前 AdminRouteBinding 编译器生成。' +} + +function routeStateClass(dirty: boolean, published: boolean) { + if (dirty || !published) return 'draft' + return 'published' +} + +function yamlSyncLabel(isEnglish: boolean, yamlError: string) { + if (yamlError) return isEnglish ? 'Syntax error' : '语法错误' + return isEnglish ? 'Synced with form' : '已与表单同步' +} + +function yamlSyncClass(yamlError: string) { + return yamlError ? 'error' : 'synced' +} + +export function RouteEditorHeader({ + isEnglish, + routeLabel, + entry, + publishStatus, + dirty, + published, + enabled, + currentStatus, + lifecycleStatus, + busy, + yamlError, + onBack, + onValidate, + onSave, + onPublish, +}: HeaderProps) { + return ( + <> + <div className="route-editor-backbar"> + <button className="route-editor-back" type="button" onClick={onBack}> + <ArrowLeft size={15} /> + {isEnglish ? 'Back to API routes' : '返回 API 路由'} + </button> + <div + className="route-editor-revisions" + aria-label={isEnglish ? 'Route revisions' : '路由版本'} + > + <span className="route-revision draft"> + {isEnglish ? 'Draft rev' : '草稿 rev'} {publishStatus?.draftRevision || '-'} + </span> + <span className="route-revision published"> + {isEnglish ? 'Published rev' : '已发布 rev'} {publishStatus?.publishedRevision || '-'} + </span> + </div> + </div> + + <div className="route-editor-titlebar"> + <div> + <p className="eyebrow">API ROUTE</p> + <h1>{routeLabel}</h1> + <div className="route-editor-summary"> + <span className={`method ${entry.method.toLowerCase()}`}>{entry.method || 'GET'}</span> + <span className="route-editor-path"> + {entry.path || (isEnglish ? 'Path required' : '需要填写路径')} + </span> + <span className={`route-editor-state ${routeStateClass(dirty, published)}`}> + <i /> {currentStatus} + </span> + <span className={`route-editor-lifecycle ${enabled ? 'enabled' : 'disabled'}`}> + <i /> {lifecycleStatus} + </span> + </div> + </div> + <div className="route-editor-actions"> + <button + className="secondary" + type="button" + disabled={busy !== '' || Boolean(yamlError)} + onClick={onValidate} + > + <CheckCircle2 size={14} /> + {actionLabel( + isEnglish, + busy, + 'validate', + translateText(isEnglish ? 'en-US' : 'zh-CN', '校验'), + )} + </button> + <button + className="secondary" + type="button" + disabled={busy !== '' || Boolean(yamlError)} + onClick={onSave} + > + <Save size={14} /> + {actionLabel(isEnglish, busy, 'save', isEnglish ? 'Save draft' : '保存草稿')} + </button> + <button + className="primary" + type="button" + disabled={busy !== '' || Boolean(yamlError)} + onClick={onPublish} + > + <Send size={14} /> + {actionLabel(isEnglish, busy, 'publish', isEnglish ? 'Publish' : '发布')} + </button> + </div> + </div> + </> + ) +} + +type ContractProps = { + readonly isEnglish: boolean +} + +export function RouteEditorContract({ isEnglish }: ContractProps) { + return ( + <div className="route-editor-contract"> + <span className="route-contract-mark">{isEnglish ? 'ATOMIC PUBLISH' : '原子发布'}</span> + <span> + {isEnglish + ? 'Saving updates this route draft. Publishing replaces only this route in one etcd transaction.' + : '保存只更新当前路由草稿。发布会通过一次 etcd 事务只替换当前路由。'} + </span> + </div> + ) +} + +type NoticeProps = { + readonly notice: Notice +} + +export function RouteEditorNotice({ notice }: NoticeProps) { + if (notice.tone === 'error') { + return ( + <div className="route-editor-notice error" role="alert"> + <AlertCircle size={16} /> + <span>{notice.text}</span> + </div> + ) + } + return ( + <output className="route-editor-notice success"> + <CheckCircle2 size={16} /> + <span>{notice.text}</span> + </output> + ) +} + +export function RouteEditorTabs({ isEnglish, activeTab, onChange }: TabsProps) { + return ( + <div + className="route-editor-tabs" + role="tablist" + aria-label={isEnglish ? 'Route editor views' : '路由编辑视图'} + > + <button + className={activeTab === 'form' ? 'active' : ''} + type="button" + role="tab" + aria-selected={activeTab === 'form'} + onClick={() => onChange('form')} + > + {isEnglish ? 'Form' : '表单'} + </button> + <button + className={activeTab === 'preview' ? 'active' : ''} + type="button" + role="tab" + aria-selected={activeTab === 'preview'} + onClick={() => onChange('preview')} + > + <Eye size={14} /> {isEnglish ? 'Preview' : '预览'} + </button> + <button + className={activeTab === 'diff' ? 'active' : ''} + type="button" + role="tab" + aria-selected={activeTab === 'diff'} + onClick={() => onChange('diff')} + > + <GitCompareArrows size={14} /> Diff + </button> + <button + className="unavailable-tab" + type="button" + disabled + title={ + isEnglish + ? 'History is not exposed by the current Admin API.' + : '当前 Admin API 尚未提供历史接口。' + } + > + {isEnglish ? 'History' : '历史'} + </button> + <button + className={activeTab === 'yaml' ? 'active' : ''} + type="button" + role="tab" + aria-selected={activeTab === 'yaml'} + onClick={() => onChange('yaml')} + > + <FileCode2 size={14} /> YAML + </button> + </div> + ) +} + +function RouteIdentityCard({ + isEnglish, + mode, + entry, + name, + issueMessage, + onNameChange, + onPathChange, + onMethodChange, +}: IdentityCardProps) { + const nameIssue = issueMessage('metadata.name') + const pathIssue = issueMessage('spec.entry.path') + const methodIssue = issueMessage('spec.entry.method') + return ( + <section className="panel route-editor-card"> + <div className="panel-head"> + <div> + <h2>{isEnglish ? 'Identity and entry' : '身份与入口'}</h2> + <span> + {isEnglish + ? 'Define the HTTP route exposed by Pixiu.' + : '定义 Pixiu 对外暴露的 HTTP 路由。'} + </span> + </div> + </div> + <div className="route-entry-fields"> + <label className="route-field"> + <span>{isEnglish ? 'Route name' : '路由名称'}</span> + <input + value={name} + readOnly={mode === 'edit'} + aria-invalid={Boolean(nameIssue)} + aria-describedby="route-name-help route-name-error" + placeholder="create-user" + onChange={(event) => onNameChange(event.target.value)} + /> + <small id="route-name-help">{routeNameHelp(isEnglish, mode)}</small> + {nameIssue && ( + <small id="route-name-error" className="route-field-error"> + {nameIssue} + </small> + )} + </label> + <label className="route-field"> + <span>{isEnglish ? 'Entry protocol' : '入口协议'}</span> + <select value={entry.protocol} disabled> + <option value="http">HTTP</option> + </select> + <small> + {isEnglish ? 'The current schema accepts HTTP only.' : '当前 schema 仅支持 HTTP。'} + </small> + </label> + <label className="route-field route-field-wide"> + <span>{isEnglish ? 'Path' : '路径'}</span> + <input + value={entry.path} + aria-invalid={Boolean(pathIssue)} + placeholder="/api/v1/users/:id" + onChange={(event) => onPathChange(event.target.value)} + /> + {pathIssue && <small className="route-field-error">{pathIssue}</small>} + </label> + <fieldset className="route-field route-field-wide route-method-fieldset"> + <legend>{isEnglish ? 'HTTP method' : '请求方法'}</legend> + <div className="route-method-options"> + {HTTP_METHODS.map((method) => ( + <button + className={entry.method === method ? 'active' : ''} + key={method} + type="button" + aria-pressed={entry.method === method} + onClick={() => onMethodChange(method)} + > + {method} + </button> + ))} + </div> + {methodIssue && <small className="route-field-error">{methodIssue}</small>} + </fieldset> + </div> + </section> + ) +} + +type TargetFieldProps = { + readonly label: string + readonly value: string + readonly placeholder: string + readonly issue: string + readonly onChange: (value: string) => void +} + +function TargetField({ label, value, placeholder, issue, onChange }: TargetFieldProps) { + return ( + <label className="route-field"> + <span>{label}</span> + <input + value={value} + placeholder={placeholder} + onChange={(event) => onChange(event.target.value)} + /> + {issue && <small className="route-field-error">{issue}</small>} + </label> + ) +} + +function RouteTargetCard({ isEnglish, target, issueMessage, onChange }: TargetCardProps) { + const fields: Array<{ + key: EditableTargetKey + label: string + placeholder: string + issuePath: string + }> = [ + { + key: 'cluster', + label: 'Cluster', + placeholder: 'user-service', + issuePath: 'spec.target.cluster', + }, + { + key: 'application', + label: 'Application', + placeholder: 'UserProvider', + issuePath: 'spec.target.application', + }, + { + key: 'interface', + label: 'Interface', + placeholder: 'com.example.user.UserService', + issuePath: 'spec.target.interface', + }, + { key: 'method', label: 'Method', placeholder: 'GetUser', issuePath: 'spec.target.method' }, + { key: 'group', label: 'Group', placeholder: 'stable', issuePath: '' }, + { key: 'version', label: 'Version', placeholder: '1.0.0', issuePath: '' }, + ] + return ( + <section className="panel route-editor-card"> + <div className="panel-head"> + <div> + <h2>{isEnglish ? 'Backend target' : '后端目标'}</h2> + <span> + {isEnglish + ? 'Map the request to one Dubbo service method.' + : '将请求映射到一个 Dubbo 服务方法。'} + </span> + </div> + </div> + <div className="route-target-fields"> + <label className="route-field"> + <span>{isEnglish ? 'Target protocol' : '目标协议'}</span> + <select value={target.protocol} disabled> + <option value="dubbo">Dubbo</option> + </select> + <small> + {isEnglish ? 'The current compiler targets Dubbo.' : '当前编译器目标为 Dubbo。'} + </small> + </label> + {fields.map((field) => ( + <TargetField + key={field.key} + label={field.label} + value={target[field.key]} + placeholder={field.placeholder} + issue={field.issuePath ? issueMessage(field.issuePath) : ''} + onChange={(value) => onChange(field.key, value)} + /> + ))} + </div> + </section> + ) +} + +type ParamRowProps = { + readonly isEnglish: boolean + readonly param: RouteBindingParam + readonly index: number + readonly issueMessage: IssueMessage + readonly onUpdate: (index: number, patch: Partial<RouteBindingParam>) => void + readonly onRemove: (index: number) => void +} + +function RouteParamRow({ + isEnglish, + param, + index, + issueMessage, + onUpdate, + onRemove, +}: ParamRowProps) { + const fromIssue = issueMessage(`spec.params[${index}].from`) + const toIssue = issueMessage(`spec.params[${index}].to`) + const typeIssue = issueMessage(`spec.params[${index}].type`) + return ( + <div className="route-param-row" key={`${index}-${param.from}`}> + <label className="route-param-field"> + <span>{isEnglish ? 'HTTP source' : 'HTTP 来源'}</span> + <input + value={param.from} + aria-invalid={Boolean(fromIssue)} + placeholder="queryStrings.page" + onChange={(event) => onUpdate(index, { from: event.target.value })} + /> + {fromIssue && <small className="route-field-error">{fromIssue}</small>} + </label> + <label className="route-param-field"> + <span>{isEnglish ? 'Argument index' : '参数位置'}</span> + <input + type="number" + min="0" + value={param.to} + aria-invalid={Boolean(toIssue)} + onChange={(event) => onUpdate(index, { to: Number(event.target.value) })} + /> + {toIssue && <small className="route-field-error">{toIssue}</small>} + </label> + <label className="route-param-field"> + <span>{isEnglish ? 'Dubbo type' : 'Dubbo 类型'}</span> + <select + value={param.type} + onChange={(event) => onUpdate(index, { type: event.target.value })} + > + {PARAM_TYPES.map((type) => ( + <option key={type} value={type}> + {type} + </option> + ))} + </select> + {typeIssue && <small className="route-field-error">{typeIssue}</small>} + </label> + <button + className="route-icon-action" + type="button" + aria-label={isEnglish ? `Remove parameter ${index + 1}` : `删除第 ${index + 1} 个参数`} + onClick={() => onRemove(index)} + > + <Trash2 size={15} /> + </button> + </div> + ) +} + +function RouteParamsCard({ + isEnglish, + params, + issueMessage, + onAdd, + onUpdate, + onRemove, +}: ParamCardProps) { + return ( + <section className="panel route-param-panel"> + <div className="panel-head route-param-head"> + <div> + <h2>{isEnglish ? 'Inbound request' : '入站请求'}</h2> + <span> + {isEnglish + ? 'Map HTTP values to zero-based Dubbo argument indexes.' + : '将 HTTP 参数映射到从 0 开始的 Dubbo 参数位置。'} + </span> + </div> + <button className="secondary route-add-param" type="button" onClick={onAdd}> + <Plus size={14} /> {isEnglish ? 'Add parameter' : '添加参数'} + </button> + </div> + <div className="route-param-table"> + {params.length === 0 ? ( + <div className="route-param-empty"> + <span>{isEnglish ? 'No parameter mappings yet.' : '暂未配置参数映射。'}</span> + <small> + {isEnglish + ? 'Add a mapping when the Dubbo method expects HTTP input.' + : '当 Dubbo 方法需要接收 HTTP 参数时,再添加映射。'} + </small> + </div> + ) : ( + <> + <div className="route-param-header" aria-hidden="true"> + <span>{isEnglish ? 'HTTP source' : 'HTTP 来源'}</span> + <span>{isEnglish ? 'Argument index' : '参数位置'}</span> + <span>{isEnglish ? 'Dubbo type' : 'Dubbo 类型'}</span> + <span /> + </div> + {params.map((param, index) => ( + <RouteParamRow + key={`${index}-${param.from}`} + isEnglish={isEnglish} + param={param} + index={index} + issueMessage={issueMessage} + onUpdate={onUpdate} + onRemove={onRemove} + /> + ))} + </> + )} + </div> + </section> + ) +} + +function RoutePublishCard({ + isEnglish, + enabled, + validate, + onEnabledChange, + onValidateChange, +}: PublishCardProps) { + return ( + <section className="panel route-publish-panel"> + <div> + <h2>{isEnglish ? 'Release and lifecycle' : '发布与生命周期'}</h2> + <span> + {isEnglish + ? 'Both lifecycle changes and validation preferences are published with this route.' + : '路由启停和校验偏好都会随当前路由一起发布。'} + </span> + </div> + <div className="route-switches"> + <label className="route-switch"> + <input + type="checkbox" + checked={enabled} + aria-label={isEnglish ? 'Enable route' : '启用路由'} + onChange={(event) => onEnabledChange(event.target.checked)} + /> + <span className="route-switch-track" /> + <span>{isEnglish ? 'Route enabled' : '启用路由'}</span> + </label> + <label className="route-switch"> + <input + type="checkbox" + checked={validate} Review Comment: [P2] Make the Validate before publish setting affect publish behavior This checkbox only updates spec.publish.validate, while the publish endpoint/server always normalizes and validates the draft without reading this field. Toggling the checkbox therefore has no effect even though the UI presents it as a publish preference. Please wire the setting through the publish contract or remove the control until it is supported. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
