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

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 7de33df87 fix(config-ui): keep grafana deep links working with an 
external grafana (#9239)
7de33df87 is described below

commit 7de33df87a9e46d20a0b5b3d0d44d7fe32180541
Author: Dirk <[email protected]>
AuthorDate: Thu Oct 22 05:18:02 2026 +0200

    fix(config-ui): keep grafana deep links working with an external grafana 
(#9239)
    
    With USE_EXTERNAL_GRAFANA=true, nginx redirected every /grafana/... request
    to GRAFANA_ENDPOINT and dropped the path and query, so all dashboard links
    (e.g. /grafana/d/<uid>/...) ended on the Grafana home page. The redirect
    now keeps the path after /grafana and the query string ($request_uri, still
    URL-encoded). A trailing slash in GRAFANA_ENDPOINT is ignored. Proxy mode is
    unchanged.
    
    The onboarding "Check Dashboard" button opened DEVLAKE_DASHBOARD_URL_*,
    which are build-time variables and are not set in the released image, so
    the button opened an empty tab. If no URL is configured, it now opens the
    Grafana dashboard search for the plugin (e.g. 
/grafana/dashboards?query=GitHub),
    which works for MySQL and PostgreSQL (PostgreSQL dashboards use "-pg" UIDs).
    
    Also document USE_EXTERNAL_GRAFANA in config-ui/README.md and the
    PostgreSQL UIDs in config-ui/env.example.
    
    Refs #9219
    
    Signed-off-by: DoDiODev <[email protected]>
---
 config-ui/README.md                                | 13 ++++
 config-ui/env.example                              |  6 ++
 config-ui/nginx.conf                               | 10 ++-
 config-ui/nginx.sh                                 |  3 +
 config-ui/src/routes/onboard/components/card.tsx   |  6 +-
 config-ui/src/routes/onboard/dashboard-url.test.ts | 49 ++++++++++++++
 config-ui/src/routes/onboard/dashboard-url.ts      | 76 ++++++++++++++++++++++
 config-ui/src/routes/onboard/step-4.tsx            | 13 +---
 8 files changed, 162 insertions(+), 14 deletions(-)

diff --git a/config-ui/README.md b/config-ui/README.md
index e1a4d3e85..4ebbd0f0f 100644
--- a/config-ui/README.md
+++ b/config-ui/README.md
@@ -67,3 +67,16 @@ $ yarn preview
 ```
 
 For actual production use, the **Docker Image** for Config-UI should be used 
as outlined in the main project README.md
+
+### Grafana in the Docker image
+
+The image serves Grafana under `/grafana/` (runtime variables, read by 
`nginx.sh`):
+
+- `GRAFANA_ENDPOINT` (`host:port`) and `GRAFANA_ENDPOINT_PROTO` (default 
`http`): Grafana is proxied under `/grafana/`.
+- `USE_EXTERNAL_GRAFANA=true`: `/grafana/` is not proxied but redirected (302) 
to `GRAFANA_ENDPOINT`, which is then
+  the full base URL of the external Grafana, e.g. 
`https://grafana.example.com` or `https://example.com/grafana`.
+  The path and query after `/grafana` are kept, so 
`/grafana/d/<uid>/<slug>?orgId=1` redirects to
+  `<GRAFANA_ENDPOINT>/d/<uid>/<slug>?orgId=1`. A trailing slash in 
`GRAFANA_ENDPOINT` is ignored.
+
+The external Grafana needs the DevLake dashboards with the same UIDs (MySQL: 
e.g. `KXWvOFQnz`; PostgreSQL: the same UID
+with a `-pg` suffix).
diff --git a/config-ui/env.example b/config-ui/env.example
index a1bff92ac..ede8ee9c1 100644
--- a/config-ui/env.example
+++ b/config-ui/env.example
@@ -27,6 +27,12 @@
 DEVLAKE_PLUGINS=
 
 # Dashboard URL
+# Opened by the "Check Dashboard" button in the onboarding. These are 
build-time
+# variables; the released image is built without them. If a value is empty or
+# not set, the button opens the Grafana dashboard search for the plugin
+# (e.g. /grafana/dashboards?query=GitHub), which works for MySQL and 
PostgreSQL.
+# The UIDs below are the MySQL dashboards. For PostgreSQL, append "-pg" to the
+# UID, e.g. 
/grafana/d/KXWvOFQnz-pg/github?orgId=1&var-repo_id=All&var-interval=WEEKDAY
 
DEVLAKE_DASHBOARD_URL_GITHUB=/grafana/d/KXWvOFQnz/github?orgId=1&var-repo_id=All&var-interval=WEEKDAY
 
DEVLAKE_DASHBOARD_URL_GITLAB=/grafana/d/msSjEq97z/gitlab?orgId=1&var-repo_id=All&var-interval=WEEKDAY
 
DEVLAKE_DASHBOARD_URL_BITBUCKET=/grafana/d/4LzQHZa4k/bitbucket?orgId=1&var-repo_id=All&var-interval=WEEKDAY
diff --git a/config-ui/nginx.conf b/config-ui/nginx.conf
index 959d8bff8..f84da1599 100644
--- a/config-ui/nginx.conf
+++ b/config-ui/nginx.conf
@@ -1,3 +1,11 @@
+# With USE_EXTERNAL_GRAFANA=true, /grafana/<path>?<query> is redirected to
+# ${GRAFANA_ENDPOINT}/<path>?<query>, so dashboard deep links keep working.
+# $request_uri is used as is (still URL-encoded, query string included).
+map $request_uri $grafana_external_path {
+  "~^/grafana(/.*)$" $1;
+  default            /;
+}
+
 server {
 ${LISTENER}
   server_name localhost;
@@ -49,7 +57,7 @@ ${SERVER_CONF}
     auth_basic off;
     set $external "${USE_EXTERNAL_GRAFANA}";
     if ($external = "true") {
-      return 302 "${GRAFANA_ENDPOINT}";
+      return 302 "${GRAFANA_ENDPOINT}$grafana_external_path";
     }
     resolver ${DNS} valid=${DNS_VALID};
     resolver_timeout 3s;
diff --git a/config-ui/nginx.sh b/config-ui/nginx.sh
index 3e216b451..4d483c7f1 100755
--- a/config-ui/nginx.sh
+++ b/config-ui/nginx.sh
@@ -45,6 +45,9 @@ export DNS=$(awk 'BEGIN{ORS=" "} $1=="nameserver" {if ($2 ~ 
":") {print "["$2"]"
 export DNS_VALID=${DNS_VALID:-300s}
 export DEVLAKE_ENDPOINT_PROTO=${DEVLAKE_ENDPOINT_PROTO:-http}
 export GRAFANA_ENDPOINT_PROTO=${GRAFANA_ENDPOINT_PROTO:-http}
+# The external Grafana redirect appends the path after /grafana (starting
+# with "/"), so drop a trailing slash to avoid "//" in the target URL.
+export GRAFANA_ENDPOINT="${GRAFANA_ENDPOINT%/}"
 envsubst '${LISTENER} ${DEVLAKE_ENDPOINT} ${DEVLAKE_ENDPOINT_PROTO} 
${GRAFANA_ENDPOINT} ${GRAFANA_ENDPOINT_PROTO} ${USE_EXTERNAL_GRAFANA} 
${SERVER_CONF} ${DNS} ${DNS_VALID}' \
     < /etc/nginx/conf.d/default.conf.tpl \
     > /etc/nginx/conf.d/default.conf
diff --git a/config-ui/src/routes/onboard/components/card.tsx 
b/config-ui/src/routes/onboard/components/card.tsx
index 2d45ffe3e..a7b6c35bf 100644
--- a/config-ui/src/routes/onboard/components/card.tsx
+++ b/config-ui/src/routes/onboard/components/card.tsx
@@ -25,7 +25,7 @@ import API from '@/api';
 import { useRefreshData, useAutoRefresh } from '@/hooks';
 import { operator } from '@/utils';
 
-import { DashboardURLMap } from '../step-4';
+import { getDashboardURL } from '../dashboard-url';
 
 interface Props {
   style?: React.CSSProperties;
@@ -157,7 +157,7 @@ export const OnboardCard = ({ style }: Props) => {
         )}
         {status === 'success' && (
           <Space>
-            <Button type="primary" onClick={() => 
window.open(DashboardURLMap[data.plugin])}>
+            <Button type="primary" onClick={() => 
window.open(getDashboardURL(data.plugin))}>
               Check Dashboard
             </Button>
             <Button onClick={handleClose}>Finish</Button>
@@ -168,7 +168,7 @@ export const OnboardCard = ({ style }: Props) => {
             <Button type="primary" onClick={() => navigate('/onboard')}>
               Details
             </Button>
-            <Button onClick={() => 
window.open(DashboardURLMap[data.plugin])}>Check Dashboard</Button>
+            <Button onClick={() => 
window.open(getDashboardURL(data.plugin))}>Check Dashboard</Button>
           </Space>
         )}
       </Flex>
diff --git a/config-ui/src/routes/onboard/dashboard-url.test.ts 
b/config-ui/src/routes/onboard/dashboard-url.test.ts
new file mode 100644
index 000000000..86cdc4747
--- /dev/null
+++ b/config-ui/src/routes/onboard/dashboard-url.test.ts
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { getDashboardURL } from './dashboard-url';
+
+describe('getDashboardURL', () => {
+  it('uses the configured dashboard URL', () => {
+    const env = { DEVLAKE_DASHBOARD_URL_GITHUB: 
'/grafana/d/KXWvOFQnz-pg/github?orgId=1' };
+    expect(getDashboardURL('github', 
env)).toBe('/grafana/d/KXWvOFQnz-pg/github?orgId=1');
+  });
+
+  it('falls back to the Grafana dashboard search when no URL is configured', 
() => {
+    expect(getDashboardURL('github', 
{})).toBe('/grafana/dashboards?query=GitHub');
+    expect(getDashboardURL('gitlab', 
{})).toBe('/grafana/dashboards?query=GitLab');
+    expect(getDashboardURL('azuredevops', 
{})).toBe('/grafana/dashboards?query=Azure%20DevOps');
+  });
+
+  it('treats empty or blank values as not configured', () => {
+    expect(getDashboardURL('gitlab', { DEVLAKE_DASHBOARD_URL_GITLAB: '' 
})).toBe('/grafana/dashboards?query=GitLab');
+    expect(getDashboardURL('gitlab', { DEVLAKE_DASHBOARD_URL_GITLAB: '  ' 
})).toBe('/grafana/dashboards?query=GitLab');
+  });
+
+  it('respects DEVLAKE_GRAFANA_PATH', () => {
+    expect(getDashboardURL('bitbucket', { DEVLAKE_GRAFANA_PATH: 
'/devlake/grafana/' })).toBe(
+      '/devlake/grafana/dashboards?query=Bitbucket',
+    );
+  });
+
+  it('opens the dashboard list for unknown plugins', () => {
+    expect(getDashboardURL('jira', {})).toBe('/grafana/dashboards');
+  });
+});
diff --git a/config-ui/src/routes/onboard/dashboard-url.ts 
b/config-ui/src/routes/onboard/dashboard-url.ts
new file mode 100644
index 000000000..30219aefb
--- /dev/null
+++ b/config-ui/src/routes/onboard/dashboard-url.ts
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ *
+ */
+
+type DashboardEnv = Partial<
+  Record<
+    | 'DEVLAKE_GRAFANA_PATH'
+    | 'DEVLAKE_DASHBOARD_URL_GITHUB'
+    | 'DEVLAKE_DASHBOARD_URL_GITLAB'
+    | 'DEVLAKE_DASHBOARD_URL_BITBUCKET'
+    | 'DEVLAKE_DASHBOARD_URL_AZUREDEVOPS'
+    | 'DEVLAKE_DASHBOARD_URL_ASANA',
+    string | undefined
+  >
+>;
+
+// Dashboard titles are the same for MySQL and PostgreSQL, while the UIDs are
+// not (PostgreSQL dashboards use a "-pg" suffix). Searching by title therefore
+// works for both databases.
+const DASHBOARD_TITLE: Record<string, string> = {
+  github: 'GitHub',
+  gitlab: 'GitLab',
+  bitbucket: 'Bitbucket',
+  azuredevops: 'Azure DevOps',
+  asana: 'Asana',
+};
+
+const configuredURL = (plugin: string, env: DashboardEnv) => {
+  switch (plugin) {
+    case 'github':
+      return env.DEVLAKE_DASHBOARD_URL_GITHUB;
+    case 'gitlab':
+      return env.DEVLAKE_DASHBOARD_URL_GITLAB;
+    case 'bitbucket':
+      return env.DEVLAKE_DASHBOARD_URL_BITBUCKET;
+    case 'azuredevops':
+      return env.DEVLAKE_DASHBOARD_URL_AZUREDEVOPS;
+    case 'asana':
+      return env.DEVLAKE_DASHBOARD_URL_ASANA;
+    default:
+      return undefined;
+  }
+};
+
+/**
+ * Returns the dashboard URL for the "Check Dashboard" button.
+ *
+ * DEVLAKE_DASHBOARD_URL_* are build-time variables. They are not set in the
+ * released image, so without a fallback the button opened an empty tab.
+ * If no URL is configured, the Grafana dashboard search for the plugin is
+ * used instead.
+ */
+export const getDashboardURL = (plugin: string, env: DashboardEnv = 
import.meta.env): string => {
+  const url = configuredURL(plugin, env)?.trim();
+  if (url) {
+    return url;
+  }
+
+  const grafanaPath = (env.DEVLAKE_GRAFANA_PATH || '/grafana').replace(/\/+$/, 
'');
+  const title = DASHBOARD_TITLE[plugin];
+  return title ? 
`${grafanaPath}/dashboards?query=${encodeURIComponent(title)}` : 
`${grafanaPath}/dashboards`;
+};
diff --git a/config-ui/src/routes/onboard/step-4.tsx 
b/config-ui/src/routes/onboard/step-4.tsx
index c7e32d635..cdfc11755 100644
--- a/config-ui/src/routes/onboard/step-4.tsx
+++ b/config-ui/src/routes/onboard/step-4.tsx
@@ -29,6 +29,7 @@ import { operator } from '@/utils';
 
 import { Logs } from './components';
 import { Context } from './context';
+import { getDashboardURL } from './dashboard-url';
 
 const Wrapper = styled.div`
   margin-top: 150px;
@@ -77,14 +78,6 @@ const Wrapper = styled.div`
   }
 `;
 
-export const DashboardURLMap: Record<string, string> = {
-  github: import.meta.env.DEVLAKE_DASHBOARD_URL_GITHUB,
-  gitlab: import.meta.env.DEVLAKE_DASHBOARD_URL_GITLAB,
-  bitbucket: import.meta.env.DEVLAKE_DASHBOARD_URL_BITBUCKET,
-  azuredevops: import.meta.env.DEVLAKE_DASHBOARD_URL_AZUREDEVOPS,
-  asana: import.meta.env.DEVLAKE_DASHBOARD_URL_ASANA,
-};
-
 const getStatus = (data: any) => {
   if (!data) {
     return 'running';
@@ -264,7 +257,7 @@ export const Step4 = () => {
           <CheckCircleOutlined style={{ fontSize: 120, color: green5 }} />
           <div className="action">
             <Space direction="vertical">
-              <Button type="primary" onClick={() => 
window.open(DashboardURLMap[plugin])}>
+              <Button type="primary" onClick={() => 
window.open(getDashboardURL(plugin))}>
                 Check Dashboard
               </Button>
               <Button loading={operating} onClick={handleFinish}>
@@ -283,7 +276,7 @@ export const Step4 = () => {
               <Button type="primary" onClick={handleRecollectData}>
                 Re-collect Data
               </Button>
-              <Button type="primary" onClick={() => 
window.open(DashboardURLMap[plugin])}>
+              <Button type="primary" onClick={() => 
window.open(getDashboardURL(plugin))}>
                 Check Dashboard
               </Button>
             </Space>

Reply via email to