mirror of
https://github.com/prometheus/prometheus.git
synced 2024-12-24 21:24:05 -08:00
Make React UI the default, keep old UI under /classic (#8142)
The React app's assets are now served under /assets, while all old custom web assets (including the ones for console templates) are now served from /classic/static. I tested different combinations of --web.external-url and --web.route-prefix with proxies in front, and I couldn't find a problem yet with the routing. Console templates also still work. While migrating old endpoints to /classic, I noticed that /version was being treated like a lot of the old UI pages, with readiness check handler in front of it, etc. I kept it in /version and removed that readiness wrapper, since it doesn't seem to be needed for that endpoint. Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
8bc369bf9b
commit
3470ee1fbf
11
Makefile
11
Makefile
|
@ -15,10 +15,11 @@
|
||||||
DOCKER_ARCHS ?= amd64 armv7 arm64 ppc64le s390x
|
DOCKER_ARCHS ?= amd64 armv7 arm64 ppc64le s390x
|
||||||
|
|
||||||
REACT_APP_PATH = web/ui/react-app
|
REACT_APP_PATH = web/ui/react-app
|
||||||
REACT_APP_SOURCE_FILES = $(wildcard $(REACT_APP_PATH)/public/* $(REACT_APP_PATH)/src/* $(REACT_APP_PATH)/tsconfig.json)
|
REACT_APP_SOURCE_FILES = $(shell find $(REACT_APP_PATH)/public/ $(REACT_APP_PATH)/src/ $(REACT_APP_PATH)/tsconfig.json)
|
||||||
REACT_APP_OUTPUT_DIR = web/ui/static/react
|
REACT_APP_OUTPUT_DIR = web/ui/static/react
|
||||||
REACT_APP_NODE_MODULES_PATH = $(REACT_APP_PATH)/node_modules
|
REACT_APP_NODE_MODULES_PATH = $(REACT_APP_PATH)/node_modules
|
||||||
REACT_APP_NPM_LICENSES_TARBALL = "npm_licenses.tar.bz2"
|
REACT_APP_NPM_LICENSES_TARBALL = "npm_licenses.tar.bz2"
|
||||||
|
REACT_APP_BUILD_SCRIPT = ./scripts/build_react_app.sh
|
||||||
|
|
||||||
PROMTOOL = ./promtool
|
PROMTOOL = ./promtool
|
||||||
TSDB_BENCHMARK_NUM_METRICS ?= 1000
|
TSDB_BENCHMARK_NUM_METRICS ?= 1000
|
||||||
|
@ -32,9 +33,9 @@ DOCKER_IMAGE_NAME ?= prometheus
|
||||||
$(REACT_APP_NODE_MODULES_PATH): $(REACT_APP_PATH)/package.json $(REACT_APP_PATH)/yarn.lock
|
$(REACT_APP_NODE_MODULES_PATH): $(REACT_APP_PATH)/package.json $(REACT_APP_PATH)/yarn.lock
|
||||||
cd $(REACT_APP_PATH) && yarn --frozen-lockfile
|
cd $(REACT_APP_PATH) && yarn --frozen-lockfile
|
||||||
|
|
||||||
$(REACT_APP_OUTPUT_DIR): $(REACT_APP_NODE_MODULES_PATH) $(REACT_APP_SOURCE_FILES)
|
$(REACT_APP_OUTPUT_DIR): $(REACT_APP_NODE_MODULES_PATH) $(REACT_APP_SOURCE_FILES) $(REACT_APP_BUILD_SCRIPT)
|
||||||
@echo ">> building React app"
|
@echo ">> building React app"
|
||||||
@./scripts/build_react_app.sh
|
@$(REACT_APP_BUILD_SCRIPT)
|
||||||
|
|
||||||
.PHONY: assets
|
.PHONY: assets
|
||||||
assets: $(REACT_APP_OUTPUT_DIR)
|
assets: $(REACT_APP_OUTPUT_DIR)
|
||||||
|
@ -46,12 +47,12 @@ assets: $(REACT_APP_OUTPUT_DIR)
|
||||||
@$(GOFMT) -w ./web/ui
|
@$(GOFMT) -w ./web/ui
|
||||||
|
|
||||||
.PHONY: react-app-lint
|
.PHONY: react-app-lint
|
||||||
react-app-lint:
|
react-app-lint:
|
||||||
@echo ">> running React app linting"
|
@echo ">> running React app linting"
|
||||||
cd $(REACT_APP_PATH) && yarn lint:ci
|
cd $(REACT_APP_PATH) && yarn lint:ci
|
||||||
|
|
||||||
.PHONY: react-app-lint-fix
|
.PHONY: react-app-lint-fix
|
||||||
react-app-lint-fix:
|
react-app-lint-fix:
|
||||||
@echo ">> running React app linting and fixing errors where possible"
|
@echo ">> running React app linting and fixing errors where possible"
|
||||||
cd $(REACT_APP_PATH) && yarn lint
|
cd $(REACT_APP_PATH) && yarn lint
|
||||||
|
|
||||||
|
|
|
@ -1,21 +1,21 @@
|
||||||
{{/* vim: set ft=html: */}}
|
{{/* vim: set ft=html: */}}
|
||||||
{{/* Load Prometheus console library JS/CSS. Should go in <head> */}}
|
{{/* Load Prometheus console library JS/CSS. Should go in <head> */}}
|
||||||
{{ define "prom_console_head" }}
|
{{ define "prom_console_head" }}
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/vendor/rickshaw/rickshaw.min.css">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/vendor/rickshaw/rickshaw.min.css">
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/vendor/bootstrap-4.5.2/css/bootstrap.min.css">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/vendor/bootstrap-4.5.2/css/bootstrap.min.css">
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/css/prom_console.css">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/css/prom_console.css">
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/vendor/bootstrap4-glyphicons/css/bootstrap-glyphicons.min.css">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/vendor/bootstrap4-glyphicons/css/bootstrap-glyphicons.min.css">
|
||||||
<script src="{{ pathPrefix }}/static/vendor/rickshaw/vendor/d3.v3.js"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/rickshaw/vendor/d3.v3.js"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/rickshaw/vendor/d3.layout.min.js"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/rickshaw/vendor/d3.layout.min.js"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/rickshaw/rickshaw.min.js"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/rickshaw/rickshaw.min.js"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/js/jquery-3.5.1.min.js"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/js/jquery-3.5.1.min.js"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/js/popper.min.js"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/js/popper.min.js"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/bootstrap-4.5.2/js/bootstrap.min.js"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/bootstrap-4.5.2/js/bootstrap.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var PATH_PREFIX = "{{ pathPrefix }}";
|
var PATH_PREFIX = "{{ pathPrefix }}";
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ pathPrefix }}/static/js/prom_console.js"></script>
|
<script src="{{ pathPrefix }}/classic/static/js/prom_console.js"></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{/* Top of all pages. */}}
|
{{/* Top of all pages. */}}
|
||||||
|
|
|
@ -73,7 +73,7 @@ versions.
|
||||||
| match | pattern, text | boolean | [regexp.MatchString](https://golang.org/pkg/regexp/#MatchString) Tests for a unanchored regexp match. |
|
| match | pattern, text | boolean | [regexp.MatchString](https://golang.org/pkg/regexp/#MatchString) Tests for a unanchored regexp match. |
|
||||||
| reReplaceAll | pattern, replacement, text | string | [Regexp.ReplaceAllString](https://golang.org/pkg/regexp/#Regexp.ReplaceAllString) Regexp substitution, unanchored. |
|
| reReplaceAll | pattern, replacement, text | string | [Regexp.ReplaceAllString](https://golang.org/pkg/regexp/#Regexp.ReplaceAllString) Regexp substitution, unanchored. |
|
||||||
| graphLink | expr | string | Returns path to graph view in the [expression browser](https://prometheus.io/docs/visualization/browser/) for the expression. |
|
| graphLink | expr | string | Returns path to graph view in the [expression browser](https://prometheus.io/docs/visualization/browser/) for the expression. |
|
||||||
| tableLink | expr | string | Returns path to tabular ("Console") view in the [expression browser](https://prometheus.io/docs/visualization/browser/) for the expression. |
|
| tableLink | expr | string | Returns path to tabular ("Table") view in the [expression browser](https://prometheus.io/docs/visualization/browser/) for the expression. |
|
||||||
|
|
||||||
### Others
|
### Others
|
||||||
|
|
||||||
|
|
|
@ -77,7 +77,7 @@ const Navigation: FC<NavbarProps> = ({ consolesLink }) => {
|
||||||
<NavLink href="https://prometheus.io/docs/prometheus/latest/getting_started/">Help</NavLink>
|
<NavLink href="https://prometheus.io/docs/prometheus/latest/getting_started/">Help</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink href={`${pathPrefix}/../graph${window.location.search}`}>Classic UI</NavLink>
|
<NavLink href={`${pathPrefix}/classic/graph${window.location.search}`}>Classic UI</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
</Nav>
|
</Nav>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
export const API_PATH = '../api/v1';
|
export const API_PATH = 'api/v1';
|
||||||
|
|
|
@ -43,7 +43,7 @@ describe('ScrapePoolList', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
scrapePoolList.update();
|
scrapePoolList.update();
|
||||||
expect(mock).toHaveBeenCalledWith('/path/prefix/../api/v1/targets?state=active', {
|
expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/targets?state=active', {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
|
@ -69,7 +69,7 @@ describe('ScrapePoolList', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
scrapePoolList.update();
|
scrapePoolList.update();
|
||||||
expect(mock).toHaveBeenCalledWith('/path/prefix/../api/v1/targets?state=active', {
|
expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/targets?state=active', {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
|
@ -92,7 +92,7 @@ describe('ScrapePoolList', () => {
|
||||||
});
|
});
|
||||||
scrapePoolList.update();
|
scrapePoolList.update();
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith('/path/prefix/../api/v1/targets?state=active', {
|
expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/targets?state=active', {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
|
|
|
@ -75,7 +75,7 @@ describe('TSDB Stats', () => {
|
||||||
});
|
});
|
||||||
page.update();
|
page.update();
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith('/path/prefix/../api/v1/status/tsdb', {
|
expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/status/tsdb', {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,10 +6,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-2">
|
<div class="col-lg-2">
|
||||||
<div class="eval_stats float-right"></div>
|
<div class="eval_stats float-right"></div>
|
||||||
<img src="{{ pathPrefix }}/static/img/ajax-loader.gif?v={{ buildVersion }}" class="spinner" alt="ajax_spinner">
|
<img src="{{ pathPrefix }}/classic/static/img/ajax-loader.gif?v={{ buildVersion }}" class="spinner" alt="ajax_spinner">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-inline">
|
<div class="form-inline">
|
||||||
<input class="btn btn-primary execute_btn" type="submit" value="Execute" name="submit">
|
<input class="btn btn-primary execute_btn" type="submit" value="Execute" name="submit">
|
||||||
<select class="custom-select form-control expression_select" name="insert_metric">
|
<select class="custom-select form-control expression_select" name="insert_metric">
|
||||||
<option value="">- insert metric at cursor -</option>
|
<option value="">- insert metric at cursor -</option>
|
||||||
|
|
|
@ -1199,7 +1199,7 @@ function init() {
|
||||||
});
|
});
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: PATH_PREFIX + "/static/js/graph/graph_template.handlebar?v=" + BUILD_VERSION,
|
url: PATH_PREFIX + "/classic/static/js/graph/graph_template.handlebar?v=" + BUILD_VERSION,
|
||||||
success: function(data) {
|
success: function(data) {
|
||||||
|
|
||||||
graphTemplate = data;
|
graphTemplate = data;
|
||||||
|
|
|
@ -612,7 +612,7 @@ PromConsole.Graph.prototype.dispatch = function() {
|
||||||
}
|
}
|
||||||
|
|
||||||
var loadingImg = document.createElement("img");
|
var loadingImg = document.createElement("img");
|
||||||
loadingImg.src = PATH_PREFIX + '/static/img/ajax-loader.gif';
|
loadingImg.src = PATH_PREFIX + '/classic/static/img/ajax-loader.gif';
|
||||||
loadingImg.alt = 'Loading...';
|
loadingImg.alt = 'Loading...';
|
||||||
loadingImg.className = 'prom_graph_loading';
|
loadingImg.className = 'prom_graph_loading';
|
||||||
this.graphTd.appendChild(loadingImg);
|
this.graphTd.appendChild(loadingImg);
|
||||||
|
@ -645,7 +645,7 @@ PromConsole._chooseNameFunction = function(data) {
|
||||||
}
|
}
|
||||||
return name + "}";
|
return name + "}";
|
||||||
};
|
};
|
||||||
|
|
||||||
// If only one label varies, use that value.
|
// If only one label varies, use that value.
|
||||||
var labelValues = {};
|
var labelValues = {};
|
||||||
for (var e = 0; e < data.length; e++) {
|
for (var e = 0; e < data.length; e++) {
|
||||||
|
@ -658,7 +658,7 @@ PromConsole._chooseNameFunction = function(data) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var multiValueLabels = [];
|
var multiValueLabels = [];
|
||||||
for (var label in labelValues) {
|
for (var label in labelValues) {
|
||||||
if (Object.keys(labelValues[label]).length > 1) {
|
if (Object.keys(labelValues[label]).length > 1) {
|
||||||
|
|
|
@ -4,14 +4,14 @@
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
<meta name="robots" content="noindex,nofollow">
|
<meta name="robots" content="noindex,nofollow">
|
||||||
<title>{{ pageTitle }}</title>
|
<title>{{ pageTitle }}</title>
|
||||||
<link rel="shortcut icon" href="{{ pathPrefix }}/static/img/favicon.ico?v={{ buildVersion }}">
|
<link rel="shortcut icon" href="{{ pathPrefix }}/classic/static/img/favicon.ico?v={{ buildVersion }}">
|
||||||
<script src="{{ pathPrefix }}/static/vendor/js/jquery-3.5.1.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/js/jquery-3.5.1.min.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/js/popper.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/js/popper.min.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/bootstrap-4.5.2/js/bootstrap.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/bootstrap-4.5.2/js/bootstrap.min.js?v={{ buildVersion }}"></script>
|
||||||
|
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/vendor/bootstrap-4.5.2/css/bootstrap.min.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/vendor/bootstrap-4.5.2/css/bootstrap.min.css?v={{ buildVersion }}">
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/css/prometheus.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/css/prometheus.css?v={{ buildVersion }}">
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/vendor/bootstrap4-glyphicons/css/bootstrap-glyphicons.min.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/vendor/bootstrap4-glyphicons/css/bootstrap-glyphicons.min.css?v={{ buildVersion }}">
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var PATH_PREFIX = "{{ pathPrefix }}";
|
var PATH_PREFIX = "{{ pathPrefix }}";
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar fixed-top navbar-expand-sm navbar-dark bg-dark">
|
<nav class="navbar fixed-top navbar-expand-sm navbar-dark bg-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
|
||||||
<button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#nav-content" aria-expanded="false" aria-controls="nav-content" aria-label="Toggle navigation">
|
<button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#nav-content" aria-expanded="false" aria-controls="nav-content" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
@ -45,22 +45,23 @@
|
||||||
{{if $consoles}}
|
{{if $consoles}}
|
||||||
<li class="nav-item"><a class="nav-link" href="{{$consoles}}">Consoles</a></li>
|
<li class="nav-item"><a class="nav-link" href="{{$consoles}}">Consoles</a></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ pathPrefix }}/alerts">Alerts</a></li>
|
<li class="nav-item"><a class="nav-link" href="{{ pathPrefix }}/classic/alerts">Alerts</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ pathPrefix }}/graph">Graph</a></li>
|
<li class="nav-item"><a class="nav-link" href="{{ pathPrefix }}/classic/graph">Graph</a></li>
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Status <span class="caret"></span></a>
|
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Status <span class="caret"></span></a>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<a class="dropdown-item" href="{{ pathPrefix }}/status">Runtime & Build Information</a>
|
<a class="dropdown-item" href="{{ pathPrefix }}/classic/status">Runtime & Build Information</a>
|
||||||
<a class="dropdown-item" href="{{ pathPrefix }}/flags">Command-Line Flags</a>
|
<a class="dropdown-item" href="{{ pathPrefix }}/classic/flags">Command-Line Flags</a>
|
||||||
<a class="dropdown-item" href="{{ pathPrefix }}/config">Configuration</a>
|
<a class="dropdown-item" href="{{ pathPrefix }}/classic/config">Configuration</a>
|
||||||
<a class="dropdown-item" href="{{ pathPrefix }}/rules">Rules</a>
|
<a class="dropdown-item" href="{{ pathPrefix }}/classic/rules">Rules</a>
|
||||||
<a class="dropdown-item" href="{{ pathPrefix }}/targets">Targets</a>
|
<a class="dropdown-item" href="{{ pathPrefix }}/classic/targets">Targets</a>
|
||||||
<a class="dropdown-item" href="{{ pathPrefix }}/service-discovery">Service Discovery</a>
|
<a class="dropdown-item" href="{{ pathPrefix }}/classic/service-discovery">Service Discovery</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class= "nav-item" >
|
<li class= "nav-item">
|
||||||
<a class ="nav-link" href="https://prometheus.io/docs/prometheus/latest/getting_started/" target="_blank">Help</a>
|
<a class ="nav-link" href="https://prometheus.io/docs/prometheus/latest/getting_started/" target="_blank">Help</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{{ pathPrefix }}/graph">New UI</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/css/alerts.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/css/alerts.css?v={{ buildVersion }}">
|
||||||
<script src="{{ pathPrefix }}/static/js/alerts.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/js/alerts.js?v={{ buildVersion }}"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
@ -39,7 +39,7 @@
|
||||||
<tr class="alert_details">
|
<tr class="alert_details">
|
||||||
<td>
|
<td>
|
||||||
<div>
|
<div>
|
||||||
<pre style="display:block; padding:9.5px; font-size:13px; color:#333; word-break:break-all; background-color:#f5f5f5; border:1px solid #ccc; border-radius:4px;" ><code>{{.HTMLSnippet pathPrefix}}</code></pre>
|
<pre style="display:block; padding:9.5px; font-size:13px; color:#333; word-break:break-all; background-color:#f5f5f5; border:1px solid #ccc; border-radius:4px;" ><code>{{.HTMLSnippet (print pathPrefix "/classic")}}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
{{if $activeAlerts}}
|
{{if $activeAlerts}}
|
||||||
<table class="table table-bordered table-hover table-sm alert_elements_table">
|
<table class="table table-bordered table-hover table-sm alert_elements_table">
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/vendor/rickshaw/rickshaw.min.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/vendor/rickshaw/rickshaw.min.css?v={{ buildVersion }}">
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/vendor/eonasdan-bootstrap-datetimepicker/bootstrap-datetimepicker.min.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/vendor/eonasdan-bootstrap-datetimepicker/bootstrap-datetimepicker.min.css?v={{ buildVersion }}">
|
||||||
|
|
||||||
<script src="{{ pathPrefix }}/static/vendor/rickshaw/vendor/d3.v3.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/rickshaw/vendor/d3.v3.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/rickshaw/vendor/d3.layout.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/rickshaw/vendor/d3.layout.min.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/rickshaw/rickshaw.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/rickshaw/rickshaw.min.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/moment/moment.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/moment/moment.min.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/moment/moment-timezone-with-data.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/moment/moment-timezone-with-data.min.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/eonasdan-bootstrap-datetimepicker/bootstrap-datetimepicker.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/eonasdan-bootstrap-datetimepicker/bootstrap-datetimepicker.min.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/bootstrap3-typeahead/bootstrap3-typeahead.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/bootstrap3-typeahead/bootstrap3-typeahead.min.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/fuzzy/fuzzy.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/fuzzy/fuzzy.js?v={{ buildVersion }}"></script>
|
||||||
|
|
||||||
<script src="{{ pathPrefix }}/static/vendor/mustache/mustache.min.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/mustache/mustache.min.js?v={{ buildVersion }}"></script>
|
||||||
<script src="{{ pathPrefix }}/static/vendor/js/jquery.selection.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/vendor/js/jquery.selection.js?v={{ buildVersion }}"></script>
|
||||||
<!-- <script src="{{ pathPrefix }}/static/vendor/js/jquery.hotkeys.js?v={{ buildVersion }}"></script> -->
|
<!-- <script src="{{ pathPrefix }}/classic/static/vendor/js/jquery.hotkeys.js?v={{ buildVersion }}"></script> -->
|
||||||
|
|
||||||
<script src="{{ pathPrefix }}/static/js/graph/index.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/js/graph/index.js?v={{ buildVersion }}"></script>
|
||||||
|
|
||||||
<script id="graph_template" type="text/x-handlebars-template"></script>
|
<script id="graph_template" type="text/x-handlebars-template"></script>
|
||||||
|
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/css/graph.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/css/graph.css?v={{ buildVersion }}">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
@ -29,7 +29,7 @@
|
||||||
<i class="glyphicon glyphicon-unchecked"></i>
|
<i class="glyphicon glyphicon-unchecked"></i>
|
||||||
<button type="button" class="search-history" title="search previous queries">Enable query history</button>
|
<button type="button" class="search-history" title="search previous queries">Enable query history</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-link btn-sm new_ui_button" onclick="window.location.pathname='{{ pathPrefix }}/new/graph'">Try experimental React UI</button>
|
<button type="button" class="btn btn-link btn-sm new_ui_button" onclick="window.location.pathname='{{ pathPrefix }}/graph'">Back to the new UI</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/css/rules.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/css/rules.css?v={{ buildVersion }}">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
@ -24,7 +24,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
{{range .Rules}}
|
{{range .Rules}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="rule_cell">{{.HTMLSnippet pathPrefix}}</td>
|
<td class="rule_cell">{{.HTMLSnippet (print pathPrefix (print pathPrefix "/classic"))}}</td>
|
||||||
<td class="state">
|
<td class="state">
|
||||||
<span class="alert alert-{{ .Health | ruleHealthToClass }} state_indicator text-uppercase">
|
<span class="alert alert-{{ .Health | ruleHealthToClass }} state_indicator text-uppercase">
|
||||||
{{.Health}}
|
{{.Health}}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/css/targets.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/css/targets.css?v={{ buildVersion }}">
|
||||||
<script src="{{ pathPrefix }}/static/js/targets.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/js/targets.js?v={{ buildVersion }}"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
*[id]:before {
|
*[id]:before {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/static/css/targets.css?v={{ buildVersion }}">
|
<link type="text/css" rel="stylesheet" href="{{ pathPrefix }}/classic/static/css/targets.css?v={{ buildVersion }}">
|
||||||
<script src="{{ pathPrefix }}/static/js/targets.js?v={{ buildVersion }}"></script>
|
<script src="{{ pathPrefix }}/classic/static/js/targets.js?v={{ buildVersion }}"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
||||||
|
|
118
web/web.go
118
web/web.go
|
@ -69,7 +69,6 @@ import (
|
||||||
|
|
||||||
// Paths that are handled by the React / Reach router that should all be served the main React app's index.html.
|
// Paths that are handled by the React / Reach router that should all be served the main React app's index.html.
|
||||||
var reactRouterPaths = []string{
|
var reactRouterPaths = []string{
|
||||||
"/",
|
|
||||||
"/alerts",
|
"/alerts",
|
||||||
"/config",
|
"/config",
|
||||||
"/flags",
|
"/flags",
|
||||||
|
@ -336,17 +335,40 @@ func New(logger log.Logger, o *Options) *Handler {
|
||||||
router.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
router.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, path.Join(o.ExternalURL.Path, "/graph"), http.StatusFound)
|
http.Redirect(w, r, path.Join(o.ExternalURL.Path, "/graph"), http.StatusFound)
|
||||||
})
|
})
|
||||||
|
router.Get("/classic/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, path.Join(o.ExternalURL.Path, "/classic/graph"), http.StatusFound)
|
||||||
|
})
|
||||||
|
|
||||||
router.Get("/alerts", readyf(h.alerts))
|
// Redirect the original React UI's path (under "/new") to its new path at the root.
|
||||||
router.Get("/graph", readyf(h.graph))
|
router.Get("/new/*path", func(w http.ResponseWriter, r *http.Request) {
|
||||||
router.Get("/status", readyf(h.status))
|
p := route.Param(r.Context(), "path")
|
||||||
router.Get("/flags", readyf(h.flags))
|
http.Redirect(w, r, path.Join(o.ExternalURL.Path, strings.TrimPrefix(p, "/new"))+"?"+r.URL.RawQuery, http.StatusFound)
|
||||||
router.Get("/config", readyf(h.serveConfig))
|
})
|
||||||
router.Get("/rules", readyf(h.rules))
|
|
||||||
router.Get("/targets", readyf(h.targets))
|
|
||||||
router.Get("/version", readyf(h.version))
|
|
||||||
router.Get("/service-discovery", readyf(h.serviceDiscovery))
|
|
||||||
|
|
||||||
|
router.Get("/classic/alerts", readyf(h.alerts))
|
||||||
|
router.Get("/classic/graph", readyf(h.graph))
|
||||||
|
router.Get("/classic/status", readyf(h.status))
|
||||||
|
router.Get("/classic/flags", readyf(h.flags))
|
||||||
|
router.Get("/classic/config", readyf(h.serveConfig))
|
||||||
|
router.Get("/classic/rules", readyf(h.rules))
|
||||||
|
router.Get("/classic/targets", readyf(h.targets))
|
||||||
|
router.Get("/classic/service-discovery", readyf(h.serviceDiscovery))
|
||||||
|
router.Get("/classic/static/*filepath", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.URL.Path = path.Join("/static", route.Param(r.Context(), "filepath"))
|
||||||
|
fs := server.StaticFileServer(ui.Assets)
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
// Make sure that "<path-prefix>/classic" is redirected to "<path-prefix>/classic/" and
|
||||||
|
// not just the naked "/classic/", which would be the default behavior of the router
|
||||||
|
// with the "RedirectTrailingSlash" option (https://godoc.org/github.com/julienschmidt/httprouter#Router.RedirectTrailingSlash),
|
||||||
|
// and which breaks users with a --web.route-prefix that deviates from the path derived
|
||||||
|
// from the external URL.
|
||||||
|
// See https://github.com/prometheus/prometheus/issues/6163#issuecomment-553855129.
|
||||||
|
router.Get("/classic", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, path.Join(o.ExternalURL.Path, "classic")+"/", http.StatusFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.Get("/version", h.version)
|
||||||
router.Get("/metrics", promhttp.Handler().ServeHTTP)
|
router.Get("/metrics", promhttp.Handler().ServeHTTP)
|
||||||
|
|
||||||
router.Get("/federate", readyf(httputil.CompressionHandler{
|
router.Get("/federate", readyf(httputil.CompressionHandler{
|
||||||
|
@ -355,51 +377,43 @@ func New(logger log.Logger, o *Options) *Handler {
|
||||||
|
|
||||||
router.Get("/consoles/*filepath", readyf(h.consoles))
|
router.Get("/consoles/*filepath", readyf(h.consoles))
|
||||||
|
|
||||||
router.Get("/static/*filepath", func(w http.ResponseWriter, r *http.Request) {
|
serveReactApp := func(w http.ResponseWriter, r *http.Request) {
|
||||||
r.URL.Path = path.Join("/static", route.Param(r.Context(), "filepath"))
|
f, err := ui.Assets.Open("/static/react/index.html")
|
||||||
fs := server.StaticFileServer(ui.Assets)
|
if err != nil {
|
||||||
fs.ServeHTTP(w, r)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
})
|
fmt.Fprintf(w, "Error opening React index.html: %v", err)
|
||||||
|
|
||||||
// Make sure that "<path-prefix>/new" is redirected to "<path-prefix>/new/" and
|
|
||||||
// not just the naked "/new/", which would be the default behavior of the router
|
|
||||||
// with the "RedirectTrailingSlash" option (https://godoc.org/github.com/julienschmidt/httprouter#Router.RedirectTrailingSlash),
|
|
||||||
// and which breaks users with a --web.route-prefix that deviates from the path derived
|
|
||||||
// from the external URL.
|
|
||||||
router.Get("/new", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Redirect(w, r, path.Join(o.ExternalURL.Path, "new")+"/", http.StatusFound)
|
|
||||||
})
|
|
||||||
|
|
||||||
router.Get("/new/*filepath", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p := route.Param(r.Context(), "filepath")
|
|
||||||
|
|
||||||
// For paths that the React/Reach router handles, we want to serve the
|
|
||||||
// index.html, but with replaced path prefix placeholder.
|
|
||||||
for _, rp := range reactRouterPaths {
|
|
||||||
if p != rp {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := ui.Assets.Open("/static/react/index.html")
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
fmt.Fprintf(w, "Error opening React index.html: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
idx, err := ioutil.ReadAll(f)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
fmt.Fprintf(w, "Error reading React index.html: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
replacedIdx := bytes.ReplaceAll(idx, []byte("CONSOLES_LINK_PLACEHOLDER"), []byte(h.consolesPath()))
|
|
||||||
replacedIdx = bytes.ReplaceAll(replacedIdx, []byte("TITLE_PLACEHOLDER"), []byte(h.options.PageTitle))
|
|
||||||
w.Write(replacedIdx)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
idx, err := ioutil.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprintf(w, "Error reading React index.html: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replacedIdx := bytes.ReplaceAll(idx, []byte("CONSOLES_LINK_PLACEHOLDER"), []byte(h.consolesPath()))
|
||||||
|
replacedIdx = bytes.ReplaceAll(replacedIdx, []byte("TITLE_PLACEHOLDER"), []byte(h.options.PageTitle))
|
||||||
|
w.Write(replacedIdx)
|
||||||
|
}
|
||||||
|
|
||||||
// For all other paths, serve auxiliary assets.
|
// Serve the React app.
|
||||||
r.URL.Path = path.Join("/static/react/", p)
|
for _, p := range reactRouterPaths {
|
||||||
|
router.Get(p, serveReactApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The favicon and manifest are bundled as part of the React app, but we want to serve
|
||||||
|
// them on the root.
|
||||||
|
for _, p := range []string{"/favicon.ico", "/manifest.json"} {
|
||||||
|
assetPath := "/static/react" + p
|
||||||
|
router.Get(p, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.URL.Path = assetPath
|
||||||
|
fs := server.StaticFileServer(ui.Assets)
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static files required by the React app.
|
||||||
|
router.Get("/static/*filepath", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.URL.Path = path.Join("/static/react/static", route.Param(r.Context(), "filepath"))
|
||||||
fs := server.StaticFileServer(ui.Assets)
|
fs := server.StaticFileServer(ui.Assets)
|
||||||
fs.ServeHTTP(w, r)
|
fs.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|
|
@ -163,14 +163,13 @@ func TestReadyAndHealthy(t *testing.T) {
|
||||||
|
|
||||||
for _, u := range []string{
|
for _, u := range []string{
|
||||||
"http://localhost:9090/-/ready",
|
"http://localhost:9090/-/ready",
|
||||||
"http://localhost:9090/version",
|
"http://localhost:9090/classic/graph",
|
||||||
"http://localhost:9090/graph",
|
"http://localhost:9090/classic/flags",
|
||||||
"http://localhost:9090/flags",
|
"http://localhost:9090/classic/rules",
|
||||||
"http://localhost:9090/rules",
|
"http://localhost:9090/classic/service-discovery",
|
||||||
"http://localhost:9090/service-discovery",
|
"http://localhost:9090/classic/targets",
|
||||||
"http://localhost:9090/targets",
|
"http://localhost:9090/classic/status",
|
||||||
"http://localhost:9090/status",
|
"http://localhost:9090/classic/config",
|
||||||
"http://localhost:9090/config",
|
|
||||||
} {
|
} {
|
||||||
resp, err = http.Get(u)
|
resp, err = http.Get(u)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
@ -194,14 +193,13 @@ func TestReadyAndHealthy(t *testing.T) {
|
||||||
for _, u := range []string{
|
for _, u := range []string{
|
||||||
"http://localhost:9090/-/healthy",
|
"http://localhost:9090/-/healthy",
|
||||||
"http://localhost:9090/-/ready",
|
"http://localhost:9090/-/ready",
|
||||||
"http://localhost:9090/version",
|
"http://localhost:9090/classic/graph",
|
||||||
"http://localhost:9090/graph",
|
"http://localhost:9090/classic/flags",
|
||||||
"http://localhost:9090/flags",
|
"http://localhost:9090/classic/rules",
|
||||||
"http://localhost:9090/rules",
|
"http://localhost:9090/classic/service-discovery",
|
||||||
"http://localhost:9090/service-discovery",
|
"http://localhost:9090/classic/targets",
|
||||||
"http://localhost:9090/targets",
|
"http://localhost:9090/classic/status",
|
||||||
"http://localhost:9090/status",
|
"http://localhost:9090/classic/config",
|
||||||
"http://localhost:9090/config",
|
|
||||||
} {
|
} {
|
||||||
resp, err = http.Get(u)
|
resp, err = http.Get(u)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
@ -276,11 +274,6 @@ func TestRoutePrefix(t *testing.T) {
|
||||||
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||||
cleanupTestResponse(t, resp)
|
cleanupTestResponse(t, resp)
|
||||||
|
|
||||||
resp, err = http.Get("http://localhost:9091" + opts.RoutePrefix + "/version")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
|
||||||
cleanupTestResponse(t, resp)
|
|
||||||
|
|
||||||
resp, err = http.Post("http://localhost:9091"+opts.RoutePrefix+"/api/v1/admin/tsdb/snapshot", "", strings.NewReader(""))
|
resp, err = http.Post("http://localhost:9091"+opts.RoutePrefix+"/api/v1/admin/tsdb/snapshot", "", strings.NewReader(""))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||||
|
@ -304,11 +297,6 @@ func TestRoutePrefix(t *testing.T) {
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
cleanupTestResponse(t, resp)
|
cleanupTestResponse(t, resp)
|
||||||
|
|
||||||
resp, err = http.Get("http://localhost:9091" + opts.RoutePrefix + "/version")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
cleanupTestResponse(t, resp)
|
|
||||||
|
|
||||||
resp, err = http.Post("http://localhost:9091"+opts.RoutePrefix+"/api/v1/admin/tsdb/snapshot", "", strings.NewReader(""))
|
resp, err = http.Post("http://localhost:9091"+opts.RoutePrefix+"/api/v1/admin/tsdb/snapshot", "", strings.NewReader(""))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
Loading…
Reference in a new issue