You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@streampark.apache.org by be...@apache.org on 2023/02/28 13:59:27 UTC

[incubator-streampark] branch dev updated: [Feature][Issue-2191][Issue-2215] Support external link #2331 (#2375)

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

benjobs pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/incubator-streampark.git


The following commit(s) were added to refs/heads/dev by this push:
     new 38eb26ca6 [Feature][Issue-2191][Issue-2215] Support external link #2331 (#2375)
38eb26ca6 is described below

commit 38eb26ca67f9828fefd47f3eb7231e8136f5f068
Author: Leomax_Sun <28...@qq.com>
AuthorDate: Tue Feb 28 21:59:19 2023 +0800

    [Feature][Issue-2191][Issue-2215] Support external link #2331 (#2375)
    
    * [Feature] Backend implementation for flink external link (#2191/#2215)
    
    * [Feature] Frontend add external link (#2191)
    
    * [Feature] Frontend render badge in details page(#2215)
    
    * [Improve] Frontend add external link permission control(#2191/#2215)
    
    * [Improve] Frontend support badges customization(#2191/#2215)
    
    * fix: change id to optional
    
    ---------
    
    Co-authored-by: asuiah <li...@gmail.com>
---
 .../src/main/assembly/script/data/mysql-data.sql   |  10 ++
 .../src/main/assembly/script/data/pgsql-data.sql   |   8 +
 .../main/assembly/script/schema/mysql-schema.sql   |  13 ++
 .../main/assembly/script/schema/pgsql-schema.sql   |  21 +++
 .../main/assembly/script/upgrade/mysql/2.1.0.sql   |  31 ++++
 .../main/assembly/script/upgrade/pgsql/2.1.0.sql   |  31 ++++
 .../core/controller/ExternalLinkController.java    | 110 ++++++++++++
 .../console/core/entity/ExternalLink.java          |  54 ++++++
 .../console/core/enums/PlaceholderType.java}       |  25 ++-
 .../console/core/mapper/ExternalLinkMapper.java}   |  14 +-
 .../console/core/service/ExternalLinkService.java} |  21 ++-
 .../core/service/impl/ExternalLinkServiceImpl.java | 119 +++++++++++++
 .../src/main/resources/db/data-h2.sql              |   9 +
 .../src/main/resources/db/schema-h2.sql            |  15 ++
 .../resources/mapper/core/ExternalLinkMapper.xml   |  30 ++++
 .../src/api/flink/setting/externalLink.ts          |  74 ++++++++
 .../api/flink/setting/types/externalLink.type.ts}  |  26 ++-
 .../src/locales/lang/en/flink/setting.ts           |  25 +++
 .../src/locales/lang/zh-CN/flink/setting.ts        |  25 +++
 .../src/views/flink/app/Detail.vue                 |  36 +++-
 .../src/views/flink/setting/View.vue               |  12 ++
 .../flink/setting/components/ExternalLinkBadge.vue |  61 +++++++
 .../flink/setting/components/ExternalLinkModal.vue | 196 +++++++++++++++++++++
 .../setting/components/ExternalLinkSetting.vue     | 128 ++++++++++++++
 .../views/flink/setting/hooks/useExternalLink.tsx  |  65 +++++++
 25 files changed, 1113 insertions(+), 46 deletions(-)

diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/data/mysql-data.sql b/streampark-console/streampark-console-service/src/main/assembly/script/data/mysql-data.sql
index 67f5d1b77..a013079cd 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/data/mysql-data.sql
+++ b/streampark-console/streampark-console-service/src/main/assembly/script/data/mysql-data.sql
@@ -118,6 +118,11 @@ insert into `t_menu` values (100067, 100053, 'view', NULL, NULL, 'variable:view'
 insert into `t_menu` values (100068, 100033, 'view', null, null, 'setting:view', null, '1', 1, null, now(), now());
 insert into `t_menu` values (100069, 100053, 'depend view', null, null, 'variable:depend_apps', null, '1', 1, NULL, now(), now());
 insert into `t_menu` values (100070, 100015, 'savepoint trigger', null, null, 'savepoint:trigger', null, '1', 1, null, now(), now());
+insert into `t_menu` values (100071, 100033, 'link view', null, null, 'externalLink:view', null, '1', 1, NULL, now(), now());
+insert into `t_menu` values (100072, 100033, 'link create', null, null, 'externalLink:create', null, '1', 1, NULL, now(), now());
+insert into `t_menu` values (100073, 100033, 'link update', null, null, 'externalLink:update', null, '1', 1, NULL, now(), now());
+insert into `t_menu` values (100074, 100033, 'link delete', null, null, 'externalLink:delete', null, '1', 1, NULL, now(), now());
+
 
 -- ----------------------------
 -- Records of t_role
@@ -188,6 +193,11 @@ insert into `t_role_menu` values (100057, 100001, 100069);
 insert into `t_role_menu` values (100058, 100002, 100057);
 insert into `t_role_menu` values (100059, 100002, 100058);
 insert into `t_role_menu` values (100060, 100002, 100069);
+insert into `t_role_menu` values (100061, 100002, 100071);
+insert into `t_role_menu` values (100062, 100002, 100072);
+insert into `t_role_menu` values (100063, 100002, 100073);
+insert into `t_role_menu` values (100064, 100002, 100074);
+
 
 -- ----------------------------
 -- Records of t_setting
diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/data/pgsql-data.sql b/streampark-console/streampark-console-service/src/main/assembly/script/data/pgsql-data.sql
index d01733fbd..e1791aac2 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/data/pgsql-data.sql
+++ b/streampark-console/streampark-console-service/src/main/assembly/script/data/pgsql-data.sql
@@ -114,6 +114,10 @@ insert into "public"."t_menu" values (100067, 100053, 'view', NULL, NULL, 'varia
 insert into "public"."t_menu" values (100068, 100033, 'view', null, null, 'setting:view', null, '1', '1', null, now(), now());
 insert into "public"."t_menu" values (100069, 100053, 'depend view', null, null, 'variable:depend_apps', null, '1', '1', NULL, now(), now());
 insert into "public"."t_menu" values (100070, 100015, 'savepoint trigger', null, null, 'savepoint:trigger', null, '1', '1', null, now(), now());
+insert into "public"."t_menu" values (100071, 100033, 'link view', null, null, 'externalLink:view', null, '1', '1', NULL, now(), now());
+insert into "public"."t_menu" values (100072, 100033, 'link create', null, null, 'externalLink:create', null, '1', '1', NULL, now(), now());
+insert into "public"."t_menu" values (100073, 100033, 'link update', null, null, 'externalLink:update', null, '1', '1', NULL, now(), now());
+insert into "public"."t_menu" values (100074, 100033, 'link delete', null, null, 'externalLink:delete', null, '1', '1', NULL, now(), now());
 
 -- ----------------------------
 -- Records of t_role
@@ -184,6 +188,10 @@ insert into "public"."t_role_menu" values (100057, 100001, 100069);
 insert into "public"."t_role_menu" values (100058, 100002, 100057);
 insert into "public"."t_role_menu" values (100059, 100002, 100058);
 insert into "public"."t_role_menu" values (100060, 100002, 100069);
+insert into "public"."t_role_menu" values (100061, 100002, 100071);
+insert into "public"."t_role_menu" values (100062, 100002, 100072);
+insert into "public"."t_role_menu" values (100063, 100002, 100073);
+insert into "public"."t_role_menu" values (100064, 100002, 100074);
 
 -- ----------------------------
 -- Records of t_setting
diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/schema/mysql-schema.sql b/streampark-console/streampark-console-service/src/main/assembly/script/schema/mysql-schema.sql
index 680a44cb9..aa3d4a8e1 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/schema/mysql-schema.sql
+++ b/streampark-console/streampark-console-service/src/main/assembly/script/schema/mysql-schema.sql
@@ -492,5 +492,18 @@ create table `t_alert_config` (
   index `inx_alert_user` (`user_id`) using btree
 ) engine = innodb default charset = utf8mb4 collate = utf8mb4_general_ci;
 
+-- ----------------------------
+-- Table of t_external_link
+-- ----------------------------
+drop table if exists `t_external_link`;
+CREATE TABLE `t_external_link` (
+  `id` bigint not null auto_increment primary key,
+  `badge_label` varchar(100) collate utf8mb4_general_ci default null,
+  `badge_name` varchar(100) collate utf8mb4_general_ci default null,
+  `badge_color` varchar(100) collate utf8mb4_general_ci default null,
+  `link_url` varchar(1000) collate utf8mb4_general_ci default null,
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'create time',
+  `modify_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'modify time'
+) engine = innodb default charset=utf8mb4 collate=utf8mb4_general_ci;
 
 set foreign_key_checks = 1;
diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/schema/pgsql-schema.sql b/streampark-console/streampark-console-service/src/main/assembly/script/schema/pgsql-schema.sql
index a41dec2b0..b6fd7e004 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/schema/pgsql-schema.sql
+++ b/streampark-console/streampark-console-service/src/main/assembly/script/schema/pgsql-schema.sql
@@ -42,6 +42,7 @@ drop table if exists "public"."t_access_token";
 drop table if exists "public"."t_flink_log";
 drop table if exists "public"."t_team";
 drop table if exists "public"."t_variable";
+drop table if exists "public"."t_external_link";
 
 -- ----------------------------
 -- drop sequence if exists
@@ -66,6 +67,7 @@ drop sequence if exists "public"."streampark_t_access_token_id_seq";
 drop sequence if exists "public"."streampark_t_flink_log_id_seq";
 drop sequence if exists "public"."streampark_t_team_id_seq";
 drop sequence if exists "public"."streampark_t_variable_id_seq";
+drop sequence if exists "public"."streampark_t_external_link_id_seq";
 
 -- ----------------------------
 -- drop trigger if exists
@@ -741,6 +743,25 @@ create index "un_user_role_inx" on "public"."t_member" using btree (
   "role_id" "pg_catalog"."int8_ops" asc nulls last
 );
 
+
+-- ----------------------------
+-- table structure for t_external_link
+-- ----------------------------
+create sequence "public"."streampark_t_external_link_id_seq"
+    increment 1 start 10000 cache 1 minvalue 10000 maxvalue 9223372036854775807;
+
+create table "public"."t_external_link" (
+  "id" int8 not null default nextval('streampark_t_external_link_id_seq'::regclass),
+  "badge_label" varchar(100) collate "pg_catalog"."default",
+  "badge_name" varchar(100) collate "pg_catalog"."default",
+  "badge_color" varchar(100) collate "pg_catalog"."default",
+  "link_url" varchar(1000) collate "pg_catalog"."default",
+  "create_time" timestamp(6) not null default timezone('UTC-8'::text, (now())::timestamp(0) without time zone),
+  "modify_time" timestamp(6) not null default timezone('UTC-8'::text, (now())::timestamp(0) without time zone))
+;
+alter table "public"."t_external_link" add constraint "t_external_link_pkey" primary key ("id");
+
+
 -- -----------------------------------------
 -- trigger for table with modify_time field
 -- -----------------------------------------
diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql b/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
index 6b818a927..5c2a9b5e9 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
+++ b/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
@@ -15,6 +15,11 @@
  * limitations under the License.
  */
 
+use streampark;
+
+set names utf8mb4;
+set foreign_key_checks = 0;
+
 -- ISSUE-2192 DDL & DML Start
 
 alter table `t_flink_savepoint` modify column `path`  varchar(1024) collate utf8mb4_general_ci default null;
@@ -28,3 +33,29 @@ insert into `t_menu` values (100070, 100015, 'savepoint trigger', null, null, 's
 alter table `t_flink_app` change column `launch` `release` tinyint default 1;
 update `t_menu` set `menu_name`='release',`perms` = 'app:release' where `menu_id` = 100025;
 -- ISSUE-2366 DDL & DML End
+
+-- Issue-2191/2215 Start
+drop table if exists `t_external_link`;
+CREATE TABLE `t_external_link` (
+  `id` bigint not null auto_increment primary key,
+  `badge_label` varchar(100) collate utf8mb4_general_ci default null,
+  `badge_name` varchar(100) collate utf8mb4_general_ci default null,
+  `badge_color` varchar(100) collate utf8mb4_general_ci default null,
+  `link_url` varchar(1000) collate utf8mb4_general_ci default null,
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'create time',
+  `modify_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'modify time'
+) engine = innodb default charset=utf8mb4 collate=utf8mb4_general_ci;
+
+insert into `t_menu` values (100071, 100033, 'link view', null, null, 'externalLink:view', null, '1', 1, NULL, now(), now());
+insert into `t_menu` values (100072, 100033, 'link create', null, null, 'externalLink:create', null, '1', 1, NULL, now(), now());
+insert into `t_menu` values (100073, 100033, 'link update', null, null, 'externalLink:update', null, '1', 1, NULL, now(), now());
+insert into `t_menu` values (100074, 100033, 'link delete', null, null, 'externalLink:delete', null, '1', 1, NULL, now(), now());
+
+insert into `t_role_menu` values (100061, 100002, 100071);
+insert into `t_role_menu` values (100062, 100002, 100072);
+insert into `t_role_menu` values (100063, 100002, 100073);
+insert into `t_role_menu` values (100064, 100002, 100074);
+-- Issue-2191/2215 DDL & DML End
+
+set foreign_key_checks = 1;
+
diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/pgsql/2.1.0.sql b/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/pgsql/2.1.0.sql
index 04220caac..0bf853197 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/pgsql/2.1.0.sql
+++ b/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/pgsql/2.1.0.sql
@@ -29,3 +29,34 @@ alter table "public"."t_flink_app" rename "launch" to "release";
 update "public"."t_menu" set "menu_name"='release',"perms" = 'app:release' where "menu_id" = 100025;
 -- ISSUE-2366 DDL & DML End
 
+
+-- Issue-2191/2215 Start
+drop table if exists "public"."t_external_link";
+drop sequence if exists "public"."streampark_t_external_link_id_seq";
+-- ----------------------------
+-- table structure for t_external_link
+-- ----------------------------
+create sequence "public"."streampark_t_external_link_id_seq"
+    increment 1 start 10000 cache 1 minvalue 10000 maxvalue 9223372036854775807;
+
+create table "public"."t_external_link" (
+  "id" int8 not null default nextval('streampark_t_external_link_id_seq'::regclass),
+  "badge_label" varchar(100) collate "pg_catalog"."default",
+  "badge_name" varchar(100) collate "pg_catalog"."default",
+  "badge_color" varchar(100) collate "pg_catalog"."default",
+  "link_url" varchar(1000) collate "pg_catalog"."default",
+  "create_time" timestamp(6) not null default timezone('UTC-8'::text, (now())::timestamp(0) without time zone),
+  "modify_time" timestamp(6) not null default timezone('UTC-8'::text, (now())::timestamp(0) without time zone))
+;
+alter table "public"."t_external_link" add constraint "t_external_link_pkey" primary key ("id");
+
+insert into "public"."t_menu" values (100071, 100033, 'link view', null, null, 'externalLink:view', null, '1', '1', NULL, now(), now());
+insert into "public"."t_menu" values (100072, 100033, 'link create', null, null, 'externalLink:create', null, '1', '1', NULL, now(), now());
+insert into "public"."t_menu" values (100073, 100033, 'link update', null, null, 'externalLink:update', null, '1', '1', NULL, now(), now());
+insert into "public"."t_menu" values (100074, 100033, 'link delete', null, null, 'externalLink:delete', null, '1', '1', NULL, now(), now());
+
+insert into "public"."t_role_menu" values (100061, 100002, 100071);
+insert into "public"."t_role_menu" values (100062, 100002, 100072);
+insert into "public"."t_role_menu" values (100063, 100002, 100073);
+insert into "public"."t_role_menu" values (100064, 100002, 100074);
+-- Issue-2191/2215 End
diff --git a/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/controller/ExternalLinkController.java b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/controller/ExternalLinkController.java
new file mode 100644
index 000000000..306ac31ed
--- /dev/null
+++ b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/controller/ExternalLinkController.java
@@ -0,0 +1,110 @@
+/*
+ * 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 org.apache.streampark.console.core.controller;
+
+import org.apache.streampark.common.util.Utils;
+import org.apache.streampark.console.base.domain.RestResponse;
+import org.apache.streampark.console.core.entity.ExternalLink;
+import org.apache.streampark.console.core.service.ExternalLinkService;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+
+import java.util.List;
+
+@Api(tags = {"FLINK_EXTERNAL_LINK_TAG"})
+@Slf4j
+@Validated
+@RestController
+@RequestMapping("/flink/externalLink")
+public class ExternalLinkController {
+
+  @Autowired private ExternalLinkService externalLinkService;
+
+  @ApiOperation(value = "List externalLink")
+  @PostMapping("/list")
+  @RequiresPermissions("externalLink:view")
+  public RestResponse list() {
+    List<ExternalLink> externalLink = externalLinkService.list();
+    return RestResponse.success(externalLink);
+  }
+
+  @ApiOperation(value = "Render externalLink by appId")
+  @ApiImplicitParams({
+    @ApiImplicitParam(
+        name = "appId",
+        value = "application id",
+        required = true,
+        paramType = "query",
+        dataTypeClass = Long.class)
+  })
+  @PostMapping("/render")
+  public RestResponse render(
+      @NotNull(message = "The flink app id cannot be null") @RequestParam("appId") Long appId) {
+    List<ExternalLink> renderedExternalLink = externalLinkService.render(appId);
+    return RestResponse.success(renderedExternalLink);
+  }
+
+  @ApiOperation(value = "Create externalLink")
+  @PostMapping("/create")
+  @RequiresPermissions("externalLink:create")
+  public RestResponse create(@Valid ExternalLink externalLink) {
+    externalLinkService.create(externalLink);
+    return RestResponse.success();
+  }
+
+  @ApiOperation(value = "Update externalLink")
+  @PostMapping("/update")
+  @RequiresPermissions("externalLink:update")
+  public RestResponse update(@Valid ExternalLink externalLink) {
+    Utils.notNull(externalLink.getId(), "The link id cannot be null");
+    externalLinkService.update(externalLink);
+    return RestResponse.success();
+  }
+
+  @ApiOperation(value = "Delete externalLink")
+  @ApiImplicitParams({
+    @ApiImplicitParam(
+        name = "id",
+        value = "external link id",
+        required = true,
+        paramType = "query",
+        dataTypeClass = Long.class)
+  })
+  @DeleteMapping("/delete")
+  @RequiresPermissions("externalLink:delete")
+  public RestResponse delete(
+      @NotNull(message = "The link id cannot be null") @RequestParam("id") Long id) {
+    externalLinkService.delete(id);
+    return RestResponse.success();
+  }
+}
diff --git a/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/entity/ExternalLink.java b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/entity/ExternalLink.java
new file mode 100644
index 000000000..2584b4fd8
--- /dev/null
+++ b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/entity/ExternalLink.java
@@ -0,0 +1,54 @@
+/*
+ * 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 org.apache.streampark.console.core.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("t_external_link")
+public class ExternalLink implements Serializable {
+  @TableId(type = IdType.AUTO)
+  private Long id;
+
+  private String badgeLabel;
+
+  @NotBlank(message = "{required}")
+  private String badgeName;
+
+  @NotBlank(message = "{required}")
+  private String badgeColor;
+
+  @NotBlank(message = "{required}")
+  private String linkUrl;
+
+  @TableField(exist = false)
+  private String renderedLinkUrl;
+
+  private Date createTime;
+
+  private Date modifyTime;
+}
diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/enums/PlaceholderType.java
similarity index 60%
copy from streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
copy to streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/enums/PlaceholderType.java
index 6b818a927..8b1ffb884 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
+++ b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/enums/PlaceholderType.java
@@ -15,16 +15,25 @@
  * limitations under the License.
  */
 
--- ISSUE-2192 DDL & DML Start
+package org.apache.streampark.console.core.enums;
 
-alter table `t_flink_savepoint` modify column `path`  varchar(1024) collate utf8mb4_general_ci default null;
+import java.io.Serializable;
 
-insert into `t_menu` values (100070, 100015, 'savepoint trigger', null, null, 'savepoint:trigger', null, '1', 1, null, now(), now());
+/** configFile Type enum */
+public enum PlaceholderType implements Serializable {
+  JOB_ID("job_id"),
 
--- ISSUE-2192 DDL & DML End
+  JOB_NAME("job_name"),
 
+  YARN_ID("yarn_id");
 
--- ISSUE-2366 DDL & DML Start
-alter table `t_flink_app` change column `launch` `release` tinyint default 1;
-update `t_menu` set `menu_name`='release',`perms` = 'app:release' where `menu_id` = 100025;
--- ISSUE-2366 DDL & DML End
+  private final String name;
+
+  PlaceholderType(String name) {
+    this.name = name;
+  }
+
+  public String get() {
+    return this.name;
+  }
+}
diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/mapper/ExternalLinkMapper.java
similarity index 60%
copy from streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
copy to streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/mapper/ExternalLinkMapper.java
index 6b818a927..0c1995e07 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
+++ b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/mapper/ExternalLinkMapper.java
@@ -15,16 +15,10 @@
  * limitations under the License.
  */
 
--- ISSUE-2192 DDL & DML Start
+package org.apache.streampark.console.core.mapper;
 
-alter table `t_flink_savepoint` modify column `path`  varchar(1024) collate utf8mb4_general_ci default null;
+import org.apache.streampark.console.core.entity.ExternalLink;
 
-insert into `t_menu` values (100070, 100015, 'savepoint trigger', null, null, 'savepoint:trigger', null, '1', 1, null, now(), now());
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 
--- ISSUE-2192 DDL & DML End
-
-
--- ISSUE-2366 DDL & DML Start
-alter table `t_flink_app` change column `launch` `release` tinyint default 1;
-update `t_menu` set `menu_name`='release',`perms` = 'app:release' where `menu_id` = 100025;
--- ISSUE-2366 DDL & DML End
+public interface ExternalLinkMapper extends BaseMapper<ExternalLink> {}
diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/ExternalLinkService.java
similarity index 60%
copy from streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
copy to streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/ExternalLinkService.java
index 6b818a927..7605dc8fa 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
+++ b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/ExternalLinkService.java
@@ -15,16 +15,21 @@
  * limitations under the License.
  */
 
--- ISSUE-2192 DDL & DML Start
+package org.apache.streampark.console.core.service;
 
-alter table `t_flink_savepoint` modify column `path`  varchar(1024) collate utf8mb4_general_ci default null;
+import org.apache.streampark.console.core.entity.ExternalLink;
 
-insert into `t_menu` values (100070, 100015, 'savepoint trigger', null, null, 'savepoint:trigger', null, '1', 1, null, now(), now());
+import com.baomidou.mybatisplus.extension.service.IService;
 
--- ISSUE-2192 DDL & DML End
+import java.util.List;
 
+public interface ExternalLinkService extends IService<ExternalLink> {
 
--- ISSUE-2366 DDL & DML Start
-alter table `t_flink_app` change column `launch` `release` tinyint default 1;
-update `t_menu` set `menu_name`='release',`perms` = 'app:release' where `menu_id` = 100025;
--- ISSUE-2366 DDL & DML End
+  void create(ExternalLink externalLink);
+
+  void delete(Long linkId);
+
+  void update(ExternalLink externalLink);
+
+  List<ExternalLink> render(Long appId);
+}
diff --git a/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/ExternalLinkServiceImpl.java b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/ExternalLinkServiceImpl.java
new file mode 100644
index 000000000..41015e544
--- /dev/null
+++ b/streampark-console/streampark-console-service/src/main/java/org/apache/streampark/console/core/service/impl/ExternalLinkServiceImpl.java
@@ -0,0 +1,119 @@
+/*
+ * 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 org.apache.streampark.console.core.service.impl;
+
+import org.apache.streampark.common.util.Utils;
+import org.apache.streampark.console.core.entity.Application;
+import org.apache.streampark.console.core.entity.ExternalLink;
+import org.apache.streampark.console.core.enums.PlaceholderType;
+import org.apache.streampark.console.core.mapper.ExternalLinkMapper;
+import org.apache.streampark.console.core.service.ApplicationService;
+import org.apache.streampark.console.core.service.ExternalLinkService;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.PropertyPlaceholderHelper;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+
+@Slf4j
+@Service
+@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
+public class ExternalLinkServiceImpl extends ServiceImpl<ExternalLinkMapper, ExternalLink>
+    implements ExternalLinkService {
+
+  @Autowired private ApplicationService applicationService;
+
+  @Override
+  public void create(ExternalLink externalLink) {
+    if (!this.check(externalLink)) {
+      return;
+    }
+    externalLink.setCreateTime(new Date());
+    externalLink.setModifyTime(new Date());
+    externalLink.setId(null);
+    this.save(externalLink);
+  }
+
+  @Override
+  public void update(ExternalLink externalLink) {
+    if (!this.check(externalLink)) {
+      return;
+    }
+    externalLink.setModifyTime(new Date());
+    baseMapper.updateById(externalLink);
+  }
+
+  @Override
+  public void delete(Long linkId) {
+    baseMapper.deleteById(linkId);
+  }
+
+  @Override
+  public List<ExternalLink> render(Long appId) {
+    Application app = applicationService.getById(appId);
+    Utils.notNull(app, "Application doesn't exist");
+    List<ExternalLink> externalLink = this.list();
+    if (externalLink != null && externalLink.size() > 0) {
+      // Render the placeholder
+      externalLink.forEach(link -> this.renderLinkUrl(link, app));
+    }
+    return externalLink;
+  }
+
+  private void renderLinkUrl(ExternalLink link, Application app) {
+    HashMap<String, String> map = new HashMap();
+    map.put(PlaceholderType.JOB_ID.get(), app.getJobId());
+    map.put(PlaceholderType.JOB_NAME.get(), app.getJobName());
+    map.put(PlaceholderType.YARN_ID.get(), app.getAppId());
+    PropertyPlaceholderHelper propertyPlaceholderHelper = new PropertyPlaceholderHelper("{", "}");
+    link.setRenderedLinkUrl(
+        propertyPlaceholderHelper.replacePlaceholders(link.getLinkUrl().trim(), map::get));
+  }
+
+  private boolean check(ExternalLink params) {
+    LambdaQueryWrapper<ExternalLink> queryWrapper = new LambdaQueryWrapper<ExternalLink>();
+    // badgeName and LinkUrl cannot be duplicated
+    queryWrapper.nested(
+        qw ->
+            qw.eq(ExternalLink::getBadgeName, params.getBadgeName())
+                .or()
+                .eq(ExternalLink::getLinkUrl, params.getLinkUrl()));
+    if (params.getId() != null) {
+      queryWrapper.and(qw -> qw.ne(ExternalLink::getId, params.getId()));
+    }
+    ExternalLink result = this.getOne(queryWrapper);
+    if (result == null) {
+      return true;
+    }
+    Utils.required(
+        !result.getBadgeName().equals(params.getBadgeName()),
+        String.format("The name: %s is already existing.", result.getBadgeName()));
+    Utils.required(
+        !result.getLinkUrl().equals(params.getLinkUrl()),
+        String.format("The linkUrl: %s is already existing.", result.getLinkUrl()));
+    return false;
+  }
+}
diff --git a/streampark-console/streampark-console-service/src/main/resources/db/data-h2.sql b/streampark-console/streampark-console-service/src/main/resources/db/data-h2.sql
index 6fb2487fd..61525007b 100644
--- a/streampark-console/streampark-console-service/src/main/resources/db/data-h2.sql
+++ b/streampark-console/streampark-console-service/src/main/resources/db/data-h2.sql
@@ -113,6 +113,11 @@ insert into `t_menu` values (100068, 100054, 'view', NULL, NULL, 'variable:view'
 insert into `t_menu` values (100069, 100034, 'view', null, null, 'setting:view', null, '1', 1, null, now(), now());
 insert into `t_menu` values (100070, 100054, 'depend view', null, null, 'variable:depend_apps', null, '1', 1, NULL, now(), now());
 insert into `t_menu` values (100071, 100015, 'savepoint trigger', null, null, 'savepoint:trigger', null, '1', 1, null, now(), now());
+insert into `t_menu` values (100072, 100033, 'link view', null, null, 'externalLink:view', null, '1', 1, null, now(), now());
+insert into `t_menu` values (100073, 100033, 'link create', null, null, 'externalLink:create', null, '1', 1, null, now(), now());
+insert into `t_menu` values (100074, 100033, 'link update', null, null, 'externalLink:update', null, '1', 1, null, now(), now());
+insert into `t_menu` values (100075, 100033, 'link delete', null, null, 'externalLink:delete', null, '1', 1, null, now(), now());
+
 
 -- ----------------------------
 -- Records of t_role
@@ -183,6 +188,10 @@ insert into `t_role_menu` values (100057, 100001, 100069);
 insert into `t_role_menu` values (100058, 100002, 100057);
 insert into `t_role_menu` values (100059, 100002, 100058);
 insert into `t_role_menu` values (100060, 100002, 100069);
+insert into `t_role_menu` values (100061, 100002, 100072);
+insert into `t_role_menu` values (100062, 100002, 100073);
+insert into `t_role_menu` values (100063, 100002, 100074);
+insert into `t_role_menu` values (100064, 100002, 100075);
 
 -- ----------------------------
 -- Records of t_setting
diff --git a/streampark-console/streampark-console-service/src/main/resources/db/schema-h2.sql b/streampark-console/streampark-console-service/src/main/resources/db/schema-h2.sql
index 0ec3d38c7..d3300dd22 100644
--- a/streampark-console/streampark-console-service/src/main/resources/db/schema-h2.sql
+++ b/streampark-console/streampark-console-service/src/main/resources/db/schema-h2.sql
@@ -441,3 +441,18 @@ create table if not exists `t_access_token` (
   `modify_time` datetime not null default current_timestamp on update current_timestamp comment 'modify time',
   primary key(`id`)
 );
+
+
+-- ----------------------------
+-- Table of t_external_link definition
+-- ----------------------------
+create table if not exists `t_external_link` (
+  `id` int generated by default as identity not null comment 'key',
+  `badge_label` varchar(100)  default null,
+  `badge_name` varchar(100)  default null,
+  `badge_color` varchar(100)  default null,
+  `link_url` varchar(1000)  default null,
+  `create_time` datetime not null default current_timestamp comment 'create time',
+  `modify_time` datetime not null default current_timestamp on update current_timestamp comment 'modify time',
+  primary key(`id`)
+);
diff --git a/streampark-console/streampark-console-service/src/main/resources/mapper/core/ExternalLinkMapper.xml b/streampark-console/streampark-console-service/src/main/resources/mapper/core/ExternalLinkMapper.xml
new file mode 100644
index 000000000..d77e08a13
--- /dev/null
+++ b/streampark-console/streampark-console-service/src/main/resources/mapper/core/ExternalLinkMapper.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+
+<mapper namespace="org.apache.streampark.console.core.mapper.ExternalLinkMapper">
+    <resultMap id="BaseResultMap" type="org.apache.streampark.console.core.entity.ExternalLink">
+        <id column="id" jdbcType="BIGINT" property="id"/>
+        <result column="badge_label" jdbcType="VARCHAR" property="badgeLabel"/>
+        <result column="badge_name" jdbcType="VARCHAR" property="badgeName"/>
+        <result column="badge_color" jdbcType="VARCHAR" property="badgeColor"/>
+        <result column="link_url" jdbcType="VARCHAR" property="linkUrl"/>
+        <result column="create_time" jdbcType="DATE" property="createTime"/>
+        <result column="modify_time" jdbcType="DATE" property="modifyTime"/>
+    </resultMap>
+</mapper>
diff --git a/streampark-console/streampark-console-webapp/src/api/flink/setting/externalLink.ts b/streampark-console/streampark-console-webapp/src/api/flink/setting/externalLink.ts
new file mode 100644
index 000000000..0dd3e0a1f
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/api/flink/setting/externalLink.ts
@@ -0,0 +1,74 @@
+/*
+ * 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
+ *
+ *    https://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 { AxiosResponse } from 'axios';
+import { ExternalLink } from './types/externalLink.type';
+import { Result } from '/#/axios';
+import { defHttp } from '/@/utils/http/axios';
+
+enum EXTERNAL_LINK_API {
+  LIST = '/flink/externalLink/list',
+  CREATE = '/flink/externalLink/create',
+  UPDATE = '/flink/externalLink/update',
+  DELETE = '/flink/externalLink/delete',
+  RENDER = '/flink/externalLink/render',
+}
+/**
+ * Get external link settings
+ * @returns {Promise<ExternalLink[]>}
+ */
+export function fetchExternalLink(): Promise<ExternalLink[]> {
+  return defHttp.post({ url: EXTERNAL_LINK_API.LIST });
+}
+
+/**
+ * Create external link
+ * @param {ExternalLink} data
+ * @returns {Promise<AxiosResponse<Result<ExternalLink>>>}
+ */
+export function fetchExternalLinkCreate(
+  data: ExternalLink,
+): Promise<AxiosResponse<Result<ExternalLink>>> {
+  return defHttp.post({ url: EXTERNAL_LINK_API.CREATE, data }, { isReturnNativeResponse: true });
+}
+
+/**
+ * Update external link
+ * @param {ExternalLink} data
+ * @returns {Promise<AxiosResponse<Result<ExternalLink>>>}
+ */
+export function fetchExternalLinkUpdate(
+  data: ExternalLink,
+): Promise<AxiosResponse<Result<ExternalLink>>> {
+  return defHttp.post({ url: EXTERNAL_LINK_API.UPDATE, data }, { isReturnNativeResponse: true });
+}
+
+/**
+ * Delete external link
+ * @returns {Promise<AxiosResponse<Result>>}
+ */
+export function fetchExternalLinkDelete(id: string): Promise<AxiosResponse<Result>> {
+  return defHttp.delete(
+    { url: EXTERNAL_LINK_API.DELETE, data: { id } },
+    { isReturnNativeResponse: true },
+  );
+}
+
+export function fetchAppExternalLink(data: {
+  appId: string;
+}): Promise<AxiosResponse<Result<ExternalLink[]>>> {
+  return defHttp.post({ url: EXTERNAL_LINK_API.RENDER, data }, { isReturnNativeResponse: true });
+}
diff --git a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql b/streampark-console/streampark-console-webapp/src/api/flink/setting/types/externalLink.type.ts
similarity index 56%
copy from streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
copy to streampark-console/streampark-console-webapp/src/api/flink/setting/types/externalLink.type.ts
index 6b818a927..56a64d528 100644
--- a/streampark-console/streampark-console-service/src/main/assembly/script/upgrade/mysql/2.1.0.sql
+++ b/streampark-console/streampark-console-webapp/src/api/flink/setting/types/externalLink.type.ts
@@ -6,7 +6,7 @@
  * (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
+ *    https://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,
@@ -14,17 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
--- ISSUE-2192 DDL & DML Start
-
-alter table `t_flink_savepoint` modify column `path`  varchar(1024) collate utf8mb4_general_ci default null;
-
-insert into `t_menu` values (100070, 100015, 'savepoint trigger', null, null, 'savepoint:trigger', null, '1', 1, null, now(), now());
-
--- ISSUE-2192 DDL & DML End
-
-
--- ISSUE-2366 DDL & DML Start
-alter table `t_flink_app` change column `launch` `release` tinyint default 1;
-update `t_menu` set `menu_name`='release',`perms` = 'app:release' where `menu_id` = 100025;
--- ISSUE-2366 DDL & DML End
+export interface ExternalLink {
+  badgeName: string;
+  badgeLabel: string;
+  badgeColor: string;
+  linkUrl: string;
+  id?: string;
+  modifyTime?: number;
+  renderedLinkUrl?: string;
+  createTime?: string;
+}
diff --git a/streampark-console/streampark-console-webapp/src/locales/lang/en/flink/setting.ts b/streampark-console/streampark-console-webapp/src/locales/lang/en/flink/setting.ts
index 3bc7ac3b1..8f9dddd22 100644
--- a/streampark-console/streampark-console-webapp/src/locales/lang/en/flink/setting.ts
+++ b/streampark-console/streampark-console-webapp/src/locales/lang/en/flink/setting.ts
@@ -20,6 +20,7 @@ export default {
     alertSetting: 'Alert Setting',
     flinkHome: 'Flink Home',
     flinkCluster: 'Flink Cluster',
+    externalLink: 'External Link',
   },
   systemSettingItems: {
     mavenSetting: {
@@ -182,6 +183,30 @@ export default {
       clusterId: 'ClusterId',
     },
   },
+  externalLink: {
+    externalLinkSetting: 'External Link',
+    form: {
+      badgeLabel: 'Label',
+      badgeLabelPlaceholder: 'Please enter label',
+      badgeName: 'Name',
+      badgeNamePlaceholder: 'Please enter name',
+      badgeNameIsRequired: 'Name is required',
+      badgeColor: 'Color',
+      badgeColorIsRequired: 'Color is required',
+      badgePreview: 'Preview',
+      linkUrl: 'Link',
+      linkUrlPlaceholder: 'Please enter link url',
+      linkUrlIsRequired: 'Link is required',
+    },
+    operateMessage: {
+      createLinkSuccessful: 'Successfully create external link!',
+      createLinkFailed: 'Create external link failed!',
+      updateLinkSuccessful: 'Successfully update external link!',
+      updateLinkFailed: 'Update external link failed!',
+      operationFailed: 'Operation failed',
+    },
+    confDeleteTitle: 'Are you sure delete this link record',
+  },
   env: {
     conf: 'Flink Conf',
     sync: 'Sync Conf',
diff --git a/streampark-console/streampark-console-webapp/src/locales/lang/zh-CN/flink/setting.ts b/streampark-console/streampark-console-webapp/src/locales/lang/zh-CN/flink/setting.ts
index 8edc086b9..f0a177723 100644
--- a/streampark-console/streampark-console-webapp/src/locales/lang/zh-CN/flink/setting.ts
+++ b/streampark-console/streampark-console-webapp/src/locales/lang/zh-CN/flink/setting.ts
@@ -20,6 +20,7 @@ export default {
     alertSetting: '告警设置',
     flinkHome: 'Flink版本',
     flinkCluster: 'Flink集群',
+    externalLink: '扩展链接 ',
   },
   systemSettingItems: {
     mavenSetting: {
@@ -179,6 +180,30 @@ export default {
       clusterId: '集群ID',
     },
   },
+  externalLink: {
+    externalLinkSetting: '扩展链接',
+    form: {
+      badgeLabel: '徽章标签',
+      badgeLabelPlaceholder: '请输入标签的名称',
+      badgeName: '徽章名字',
+      badgeNamePlaceholder: '请输入徽章名字',
+      badgeNameIsRequired: '徽章名字是必填',
+      badgeColor: '徽章颜色',
+      badgeColorIsRequired: '徽章颜色是必选',
+      badgePreview: '徽章预览',
+      linkUrl: '链接',
+      linkUrlPlaceholder: '请输入链接',
+      linkUrlIsRequired: '链接是必填',
+    },
+    operateMessage: {
+      createLinkSuccessful: '成功新建扩展链接!',
+      createLinkFailed: '创建扩展链接失败!',
+      updateLinkSuccessful: '成功更新扩展链接!',
+      updateLinkFailed: '更新扩展链接失败!',
+      operationFailed: '操作失败',
+    },
+    confDeleteTitle: '请确认删除该条记录',
+  },
   env: {
     conf: 'Flink 配置',
     sync: '配置同步',
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/app/Detail.vue b/streampark-console/streampark-console-webapp/src/views/flink/app/Detail.vue
index cd5610a18..757dd6e88 100644
--- a/streampark-console/streampark-console-webapp/src/views/flink/app/Detail.vue
+++ b/streampark-console/streampark-console-webapp/src/views/flink/app/Detail.vue
@@ -18,6 +18,8 @@
   import { defineComponent } from 'vue';
   import { AppStateEnum, ExecModeEnum } from '/@/enums/flinkEnum';
   import { useI18n } from '/@/hooks/web/useI18n';
+  import { fetchAppExternalLink } from '/@/api/flink/setting/externalLink';
+  import { ExternalLink } from '/@/api/flink/setting/types/externalLink.type';
   export default defineComponent({
     name: 'ApplicationDetail',
   });
@@ -28,10 +30,10 @@
   import { Icon } from '/@/components/Icon';
   import { useRoute, useRouter } from 'vue-router';
   import { fetchBackUps, fetchGet, fetchOptionLog, fetchYarn } from '/@/api/flink/app/app';
-  import { onUnmounted, reactive, h, unref, ref } from 'vue';
+  import { onUnmounted, reactive, h, unref, ref, onMounted, computed } from 'vue';
   import { useIntervalFn, useClipboard } from '@vueuse/core';
   import { AppListRecord } from '/@/api/flink/app/app.type';
-  import { Tooltip, Divider } from 'ant-design-vue';
+  import { Tooltip, Divider, Space } from 'ant-design-vue';
   import { handleView } from './utils';
   import { Button } from '/@/components/Button';
   import { getDescSchema } from './data/detail.data';
@@ -44,6 +46,7 @@
   import DetailTab from './components/AppDetail/DetailTab.vue';
   import { createDetailProviderContext } from './hooks/useDetailContext';
   import { useDrawer } from '/@/components/Drawer';
+  import ExternalLinkBadge from '../setting/components/ExternalLinkBadge.vue';
 
   const route = useRoute();
   const router = useRouter();
@@ -55,6 +58,7 @@
   const { t } = useI18n();
 
   const yarn = ref('');
+  const externalLinks = ref<ExternalLink[]>([]);
   const app = reactive<Partial<AppListRecord>>({});
   const detailTabs = reactive({
     showConf: false,
@@ -218,23 +222,45 @@
     window.open(res);
   }
 
+  async function getExternalLinks() {
+    const { data: links } = await fetchAppExternalLink({ appId: route.query.appId as string });
+    externalLinks.value = links.data;
+  }
+
+  onMounted(() => {
+    getExternalLinks();
+  });
+
   onUnmounted(() => {
     pause();
   });
+
+  const appNotRunning = computed(
+    () => app.state !== AppStateEnum.RUNNING || (yarn.value === null && app.flinkRestUrl === null),
+  );
 </script>
 <template>
   <PageWrapper content-full-height content-background contentClass="p-24px">
     <div class="mb-15px">
       <span class="app-bar">{{ t('flink.app.detail.detailTitle') }}</span>
+      <Space class="-mt-8px">
+        <div v-for="link in externalLinks" :key="link.id">
+          <ExternalLinkBadge
+            :label="link.badgeLabel"
+            :redirect="link.renderedLinkUrl"
+            :color="link.badgeColor"
+            :message="link.badgeName"
+            :disabled="appNotRunning"
+          />
+        </div>
+      </Space>
       <a-button type="primary" shape="circle" @click="router.back()" class="float-right -mt-8px">
         <Icon icon="ant-design:arrow-left-outlined" />
       </a-button>
       <a-button
         type="danger"
         @click="handleFlinkView"
-        :disabled="
-          app.state !== AppStateEnum.RUNNING || (yarn === null && app.flinkRestUrl === null)
-        "
+        :disabled="appNotRunning"
         class="float-right -mt-8px mr-20px"
       >
         <Icon icon="ant-design:cloud-outlined" />
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/setting/View.vue b/streampark-console/streampark-console-webapp/src/views/flink/setting/View.vue
index 061770201..5ff8212bf 100644
--- a/streampark-console/streampark-console-webapp/src/views/flink/setting/View.vue
+++ b/streampark-console/streampark-console-webapp/src/views/flink/setting/View.vue
@@ -42,6 +42,15 @@
             <FlinkClusterSetting />
           </Card>
         </TabPane>
+        <TabPane
+          :tab="t('flink.setting.settingTab.externalLink')"
+          key="externalLink"
+          v-if="hasPermission('externalLink:view')"
+        >
+          <Card :bordered="false" class="system-setting">
+            <ExternalLinkSetting />
+          </Card>
+        </TabPane>
       </Tabs>
     </div>
   </PageWrapper>
@@ -54,12 +63,15 @@
   import AlertSetting from './components/AlertSetting.vue';
   import FlinkEnvSetting from './components/FlinkEnvSetting.vue';
   import FlinkClusterSetting from './components/FlinkClusterSetting.vue';
+  import ExternalLinkSetting from './components/ExternalLinkSetting.vue';
   import { useRoute } from 'vue-router';
   import { useI18n } from '/@/hooks/web/useI18n';
+  import { usePermission } from '/@/hooks/web/usePermission';
 
   const { t } = useI18n();
   const TabPane = Tabs.TabPane;
   const route = useRoute();
+  const { hasPermission } = usePermission();
 
   const activeKey = ref<string>((route?.query?.activeKey as string) || 'system');
 </script>
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/setting/components/ExternalLinkBadge.vue b/streampark-console/streampark-console-webapp/src/views/flink/setting/components/ExternalLinkBadge.vue
new file mode 100644
index 000000000..805be146b
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/views/flink/setting/components/ExternalLinkBadge.vue
@@ -0,0 +1,61 @@
+<!--
+  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
+
+      https://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.
+-->
+<template>
+  <div>
+    <a :href="redirect" @click.prevent="handleClick" :class="{ 'badge-disabled': disabled }">
+      <span v-if="hasLabel" class="badge-link">{{ label }}</span>
+      <span class="badge-link" :style="{ backgroundColor: color }">
+        {{ message }}
+      </span>
+    </a>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed } from 'vue';
+  interface ExternalLinkBadgeProps {
+    label: String;
+    message: String;
+    color?: string;
+    redirect?: string;
+    disabled?: boolean;
+    onBadgeClick?: () => void;
+  }
+  const props = defineProps<ExternalLinkBadgeProps>();
+  const hasLabel = computed(() => !!props.label);
+  const isClickable = computed(() => !!props.redirect && !props.disabled);
+  function handleClick() {
+    props.onBadgeClick?.() ?? (isClickable.value && window.open(props.redirect, '_blank'));
+  }
+</script>
+<style lang="less">
+  .badge-link {
+    display: inline-flexbox;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    background-color: #484f58;
+    padding: 4px;
+    min-width: 40px;
+  }
+  .badge-disabled {
+    opacity: 0.5;
+    &:hover {
+      cursor: not-allowed;
+    }
+  }
+</style>
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/setting/components/ExternalLinkModal.vue b/streampark-console/streampark-console-webapp/src/views/flink/setting/components/ExternalLinkModal.vue
new file mode 100644
index 000000000..69d6965bf
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/views/flink/setting/components/ExternalLinkModal.vue
@@ -0,0 +1,196 @@
+<!--
+  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
+
+      https://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.
+-->
+
+<script lang="ts">
+  import { defineComponent } from 'vue';
+  export default defineComponent({
+    name: 'ExternalLinkModal',
+  });
+</script>
+<script lang="ts" setup>
+  import { BasicForm, useForm } from '/@/components/Form';
+  import { BasicModal, useModalInner } from '/@/components/Modal';
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import { Icon } from '/@/components/Icon';
+  import {
+    fetchExternalLinkCreate,
+    fetchExternalLinkUpdate,
+  } from '/@/api/flink/setting/externalLink';
+
+  import { useMessage } from '/@/hooks/web/useMessage';
+  import { ExternalLink } from '/@/api/flink/setting/types/externalLink.type';
+  import { ResultEnum } from '/@/enums/httpEnum';
+  import { renderColorField, renderPreview } from '../hooks/useExternalLink';
+
+  import { h, ref } from 'vue';
+
+  const DEFAULT_BADGE_COLOR = '#eb8c34';
+  const emit = defineEmits(['reload', 'register']);
+  const { t } = useI18n();
+  const externalLinkId = ref<string | null>(null);
+  const { Swal } = useMessage();
+  const [registerForm, { validate, resetFields, setFieldsValue }] = useForm({
+    labelWidth: 120,
+    schemas: [
+      {
+        field: 'badgeLabel',
+        label: t('flink.setting.externalLink.form.badgeLabel'),
+        component: 'Input',
+        componentProps: {
+          placeholder: t('flink.setting.externalLink.form.badgeLabelPlaceholder'),
+          allowClear: true,
+        },
+      },
+      {
+        field: 'badgeName',
+        label: t('flink.setting.externalLink.form.badgeName'),
+        component: 'Input',
+        componentProps: {
+          placeholder: t('flink.setting.externalLink.form.badgeNamePlaceholder'),
+          allowClear: true,
+        },
+        rules: [
+          {
+            required: true,
+            message: t('flink.setting.externalLink.form.badgeNameIsRequired'),
+          },
+        ],
+      },
+      {
+        field: 'badgeColor',
+        label: t('flink.setting.externalLink.form.badgeColor'),
+        component: 'Input',
+        defaultValue: DEFAULT_BADGE_COLOR,
+        render: ({ model, field }) => renderColorField(model, field),
+        rules: [
+          {
+            required: true,
+            message: t('flink.setting.externalLink.form.badgeColorIsRequired'),
+          },
+        ],
+      },
+      {
+        field: 'preview',
+        label: t('flink.setting.externalLink.form.badgePreview'),
+        component: 'Input',
+        render: ({ model }) => renderPreview(model),
+      },
+      {
+        field: 'linkUrl',
+        label: t('flink.setting.externalLink.form.linkUrl'),
+        component: 'Input',
+        componentProps: {
+          placeholder: t('flink.setting.externalLink.form.linkUrlPlaceholder'),
+          allowClear: true,
+        },
+        afterItem: () =>
+          h(
+            'span',
+            { class: 'conf-switch' },
+            'Supported variables: {job_id}, {yarn_id}, {job_name},Example: https://grafana/flink-monitoring?var-JobId=var-JobId={job_id}',
+          ),
+        rules: [
+          {
+            required: true,
+            message: t('flink.setting.externalLink.form.linkUrlIsRequired'),
+          },
+        ],
+      },
+    ],
+    colon: true,
+    showActionButtonGroup: false,
+    labelCol: { lg: { span: 6, offset: 0 }, sm: { span: 6, offset: 0 } },
+    wrapperCol: { lg: { span: 16, offset: 0 }, sm: { span: 4, offset: 0 } },
+    baseColProps: { span: 24 },
+  });
+
+  const [registerModal, { closeModal, changeOkLoading }] = useModalInner((data: ExternalLink) => {
+    resetFields();
+    externalLinkId.value = '';
+    if (data && Object.keys(data).length > 0) {
+      externalLinkId.value = data.id;
+      setFieldsValue(data);
+    }
+  });
+
+  // submit new external link
+  async function handleSubmit() {
+    try {
+      changeOkLoading(true);
+      const formValue = await validate();
+      if (externalLinkId.value) {
+        const { data } = await fetchExternalLinkUpdate({
+          id: externalLinkId.value,
+          badgeLabel: formValue.badgeLabel,
+          badgeColor: formValue.badgeColor,
+          badgeName: formValue.badgeName,
+          linkUrl: formValue.linkUrl,
+        });
+        if (data.code === ResultEnum.SUCCESS) {
+          Swal.fire({
+            icon: 'success',
+            title: t('flink.setting.externalLink.operateMessage.updateLinkSuccessful'),
+            showConfirmButton: false,
+            timer: 2000,
+          });
+        } else {
+          Swal.fire({
+            icon: 'error',
+            title: t('flink.setting.externalLink.operateMessage.updateLinkFailed'),
+            text: data['message'].replaceAll(/\[StreamPark]/g, ''),
+          });
+        }
+      } else {
+        const { data } = await fetchExternalLinkCreate(formValue);
+        if (data.code === ResultEnum.SUCCESS) {
+          Swal.fire({
+            icon: 'success',
+            title: t('flink.setting.externalLink.operateMessage.createLinkSuccessful'),
+            showConfirmButton: false,
+            timer: 2000,
+          });
+        } else {
+          Swal.fire({
+            title: t('flink.setting.externalLink.operateMessage.createLinkFailed'),
+            text: data['message'].replaceAll(/\[StreamPark]/g, ''),
+            icon: 'error',
+          });
+        }
+      }
+      closeModal();
+      emit('reload');
+    } catch (error: any) {
+      console.error(error);
+    } finally {
+      changeOkLoading(false);
+    }
+  }
+</script>
+<template>
+  <BasicModal
+    @register="registerModal"
+    :ok-text="t('common.submitText')"
+    v-bind="$attrs"
+    @ok="handleSubmit"
+  >
+    <template #title>
+      <Icon icon="ant-design:link-outlined" color="blue" />
+      {{ t('flink.setting.externalLink.externalLinkSetting') }}
+    </template>
+    <BasicForm @register="registerForm" />
+  </BasicModal>
+</template>
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/setting/components/ExternalLinkSetting.vue b/streampark-console/streampark-console-webapp/src/views/flink/setting/components/ExternalLinkSetting.vue
new file mode 100644
index 000000000..cdeb9e854
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/views/flink/setting/components/ExternalLinkSetting.vue
@@ -0,0 +1,128 @@
+<!--
+  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
+
+      https://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.
+-->
+
+<script lang="ts">
+  import { defineComponent } from 'vue';
+
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import { ExternalLink } from '/@/api/flink/setting/types/externalLink.type';
+  import { useMessage } from '/@/hooks/web/useMessage';
+
+  export default defineComponent({
+    name: 'ExternalLinkSetting',
+  });
+</script>
+<script lang="ts" setup name="ExternalLinkSetting">
+  import { onMounted, ref } from 'vue';
+  import { PlusOutlined } from '@ant-design/icons-vue';
+  import { ColumnsType } from 'ant-design-vue/lib/table';
+  import { useModal } from '/@/components/Modal';
+  import ExternalLinkModal from './ExternalLinkModal.vue';
+  import ExternalLinkBadge from './ExternalLinkBadge.vue';
+  import { Table, Popconfirm } from 'ant-design-vue';
+  import { fetchExternalLink, fetchExternalLinkDelete } from '/@/api/flink/setting/externalLink';
+
+  const [registerLinkModal, { openModal: openLinkModal }] = useModal();
+  const { Swal } = useMessage();
+  const { t } = useI18n();
+
+  const externalLinks = ref<ExternalLink[]>([]);
+
+  /* Get external link list */
+  async function getExternalLink() {
+    const externalLinkList = await fetchExternalLink();
+    externalLinks.value = externalLinkList;
+  }
+
+  onMounted(() => {
+    getExternalLink();
+  });
+
+  /* delete external link */
+  async function handleDeleteExternalLink(id: string) {
+    try {
+      await fetchExternalLinkDelete(id);
+      Swal.fire({
+        icon: 'success',
+        title: 'Delete Alert Config  successful!',
+        showConfirmButton: false,
+        timer: 2000,
+      });
+      await getExternalLink();
+    } catch (error: any) {
+      console.log(error);
+    }
+  }
+
+  /* Edit button */
+  function handleEditExternalLink(item: ExternalLink) {
+    openLinkModal(true, item);
+  }
+  const columns: ColumnsType<ExternalLink> = [
+    { key: 'badgeColor', dataIndex: 'badgeColor' },
+    { key: 'linkUrl', dataIndex: 'linkUrl', ellipsis: true },
+    {
+      key: 'action',
+      dataIndex: 'action',
+      fixed: 'right',
+      align: 'right',
+    },
+  ];
+
+  const ATable = Table;
+  const APopconfirm = Popconfirm;
+</script>
+<template>
+  <div v-auth="'externalLink:create'" style="margin-bottom: 20px">
+    <a-button type="dashed" style="width: 100%; margin-top: 20px" @click="openLinkModal(true, {})">
+      <plus-outlined />
+      {{ t('common.add') }}
+    </a-button>
+  </div>
+  <a-table :showHeader="false" :data-source="externalLinks" :columns="columns" :pagination="false">
+    <template #bodyCell="{ column, record }">
+      <template v-if="column.key === 'badgeColor'">
+        <ExternalLinkBadge
+          :color="record.badgeColor"
+          :label="record.badgeLabel"
+          :redirect="record.linkUrl"
+          :message="record.badgeName"
+        />
+      </template>
+      <template v-else-if="column.key === 'action'">
+        <span>
+          <a-button
+            v-auth="'externalLink:update'"
+            type="link"
+            @click="handleEditExternalLink(record)"
+            >{{ t('common.edit') }}</a-button
+          >
+          <a-popconfirm
+            :title="t('flink.setting.externalLink.confDeleteTitle')"
+            @confirm="handleDeleteExternalLink(record.id)"
+            placement="topRight"
+          >
+            <a-button v-auth="'externalLink:delete'" danger type="text">{{
+              t('common.delText')
+            }}</a-button>
+          </a-popconfirm>
+        </span>
+      </template>
+    </template>
+  </a-table>
+  <ExternalLinkModal @register="registerLinkModal" width="850px" @reload="getExternalLink" />
+</template>
diff --git a/streampark-console/streampark-console-webapp/src/views/flink/setting/hooks/useExternalLink.tsx b/streampark-console/streampark-console-webapp/src/views/flink/setting/hooks/useExternalLink.tsx
new file mode 100644
index 000000000..74da072d8
--- /dev/null
+++ b/streampark-console/streampark-console-webapp/src/views/flink/setting/hooks/useExternalLink.tsx
@@ -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
+ *
+ *    https://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 { Input, Space } from 'ant-design-vue';
+
+import ExternalLinkBadge from '../components/ExternalLinkBadge.vue';
+import { useI18n } from '/@/hooks/web/useI18n';
+
+const { t } = useI18n();
+
+const DEFAULT_BLUE_HEX = '#2970e3';
+const DEFAULT_GREEN_HEX = '#10b53f';
+
+export const renderColorField = (formModel: Recordable, field: string) => {
+  const handlePromptColorUpdate = (color: string) => () => {
+    formModel[field] = color;
+  };
+  return (
+    <Space>
+      <Input
+        style="width:200px"
+        type="color"
+        allowClear
+        value={formModel[field]}
+        onInput={(e) => (formModel[field] = e.target.value)}
+      />
+      <ExternalLinkBadge
+        label={t('flink.setting.externalLink.form.badgeLabel')}
+        message={t('flink.setting.externalLink.form.badgeName')}
+        color={DEFAULT_GREEN_HEX}
+        onBadgeClick={handlePromptColorUpdate(DEFAULT_GREEN_HEX)}
+      />
+      <ExternalLinkBadge
+        label="Fink"
+        message="Metrics"
+        color={DEFAULT_BLUE_HEX}
+        onBadgeClick={handlePromptColorUpdate(DEFAULT_BLUE_HEX)}
+      />
+    </Space>
+  );
+};
+
+export const renderPreview = (formModel: Recordable) => {
+  return (
+    <ExternalLinkBadge
+      label={formModel['badgeLabel']}
+      message={formModel['badgeName'] || t('flink.setting.externalLink.form.badgeName')}
+      color={formModel['badgeColor']}
+    />
+  );
+};