Compare commits
5 Commits
vault/deve
...
poc-search
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37704d400b | ||
|
|
f4de5f279a | ||
|
|
d03c66ff12 | ||
|
|
6bd4370ab1 | ||
|
|
83102b9178 |
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/handler/registries"
|
||||
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
|
||||
"github.com/portainer/portainer/api/http/handler/roles"
|
||||
"github.com/portainer/portainer/api/http/handler/search"
|
||||
"github.com/portainer/portainer/api/http/handler/settings"
|
||||
"github.com/portainer/portainer/api/http/handler/ssl"
|
||||
"github.com/portainer/portainer/api/http/handler/stacks"
|
||||
@@ -59,6 +60,7 @@ type Handler struct {
|
||||
RegistryHandler *registries.Handler
|
||||
ResourceControlHandler *resourcecontrols.Handler
|
||||
RoleHandler *roles.Handler
|
||||
SearchHandler *search.Handler
|
||||
SettingsHandler *settings.Handler
|
||||
SSLHandler *ssl.Handler
|
||||
StackHandler *stacks.Handler
|
||||
@@ -201,6 +203,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.StripPrefix("/api", h.ResourceControlHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/roles"):
|
||||
http.StripPrefix("/api", h.RoleHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/search"):
|
||||
http.StripPrefix("/api", h.SearchHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/settings"):
|
||||
http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/stacks"):
|
||||
|
||||
26
api/http/handler/search/handler.go
Normal file
26
api/http/handler/search/handler.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle tag operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
DataStore portainer.DataStore
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage search operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
}
|
||||
h.Handle("/search",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.search))).Methods(http.MethodGet)
|
||||
return h
|
||||
}
|
||||
315
api/http/handler/search/search.go
Normal file
315
api/http/handler/search/search.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"fmt"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
)
|
||||
|
||||
type searchResponse struct {
|
||||
Results []searchResult `json:"Results"`
|
||||
ResultCount int `json:"ResultCount"`
|
||||
}
|
||||
|
||||
type searchResult struct {
|
||||
Label string `json:"Label"`
|
||||
ResultType string `json:"Type"`
|
||||
Environment string `json:"Environment"`
|
||||
EnvironmentType string `json:"EnvironmentType"`
|
||||
ResourceID string `json:"ResourceID"`
|
||||
EnvironmentID int `json:"EnvironmentID"`
|
||||
}
|
||||
|
||||
// @id Search
|
||||
// @summary Search for resources
|
||||
// @description Search for any resource inside this Portainer instance (through snapshots).
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags search
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param query query string true "Query used to search and filter resources"
|
||||
// @success 200 {object} searchResponse "Success"
|
||||
// @failure 400 "Bad request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /search [get]
|
||||
func (handler *Handler) search(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
query, err := request.RetrieveQueryParameter(r, "query", false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: query", err}
|
||||
}
|
||||
query = strings.ToLower(query)
|
||||
|
||||
endpoints, err := handler.DataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err}
|
||||
}
|
||||
|
||||
results := make([]searchResult, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
|
||||
if match(endpoint.Name, query) {
|
||||
|
||||
result := searchResult{
|
||||
Label: endpoint.Name,
|
||||
ResultType: "ENVIRONMENT",
|
||||
EnvironmentType: envTypeFromEndpoint(&endpoint),
|
||||
EnvironmentID: int(endpoint.ID),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
if len(endpoint.Snapshots) > 0 {
|
||||
|
||||
containers, _ := containerMatch(&endpoint, query)
|
||||
results = append(results, containers...)
|
||||
|
||||
images, _ := imageMatch(&endpoint, query)
|
||||
results = append(results, images...)
|
||||
|
||||
networks, _ := networkMatch(&endpoint, query)
|
||||
results = append(results, networks...)
|
||||
|
||||
volumes, _ := volumeMatch(&endpoint, query)
|
||||
results = append(results, volumes...)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
searchResponse := searchResponse{
|
||||
Results: results,
|
||||
ResultCount: len(results),
|
||||
}
|
||||
|
||||
return response.JSON(w, searchResponse)
|
||||
}
|
||||
|
||||
func volumeMatch(endpoint *portainer.Endpoint, query string) ([]searchResult, error) {
|
||||
results := make([]searchResult, 0)
|
||||
|
||||
volumeRoot, ok := endpoint.Snapshots[0].SnapshotRaw.Volumes.(map[string]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Unable to retrieve volume data from snapshot")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
if volumeRoot["Volumes"] == nil {
|
||||
fmt.Println("Unable to retrieve volume data from snapshot")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
volumes, ok := volumeRoot["Volumes"].([]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Unable to retrieve volume data from snapshot")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
for _, volume := range volumes {
|
||||
|
||||
volumeObject, ok := volume.(map[string]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Unable to retrieve volume data from volumes snapshot")
|
||||
continue
|
||||
}
|
||||
|
||||
name := volumeObject["Name"]
|
||||
if name == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
volumeName := name.(string)
|
||||
if match(volumeName, query) {
|
||||
result := searchResult{
|
||||
Label: volumeName,
|
||||
ResultType: "VOLUME",
|
||||
Environment: endpoint.Name,
|
||||
EnvironmentType: envTypeFromEndpoint(endpoint),
|
||||
EnvironmentID: int(endpoint.ID),
|
||||
ResourceID: volumeName,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func networkMatch(endpoint *portainer.Endpoint, query string) ([]searchResult, error) {
|
||||
results := make([]searchResult, 0)
|
||||
|
||||
networks, ok := endpoint.Snapshots[0].SnapshotRaw.Networks.([]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Unable to retrieve networks data from snapshot")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
for _, network := range networks {
|
||||
|
||||
networkObject, ok := network.(map[string]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Unable to retrieve network data from networks snapshot")
|
||||
continue
|
||||
}
|
||||
|
||||
name := networkObject["Name"]
|
||||
if name == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
id := networkObject["Id"]
|
||||
if id == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
networkName := name.(string)
|
||||
networkId := id.(string)
|
||||
if match(networkName, query) {
|
||||
result := searchResult{
|
||||
Label: networkName,
|
||||
ResultType: "NETWORK",
|
||||
Environment: endpoint.Name,
|
||||
EnvironmentType: envTypeFromEndpoint(endpoint),
|
||||
EnvironmentID: int(endpoint.ID),
|
||||
ResourceID: networkId,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func imageMatch(endpoint *portainer.Endpoint, query string) ([]searchResult, error) {
|
||||
results := make([]searchResult, 0)
|
||||
|
||||
images, ok := endpoint.Snapshots[0].SnapshotRaw.Images.([]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Unable to retrieve images data from snapshot")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
for _, image := range images {
|
||||
|
||||
imgObject, ok := image.(map[string]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Unable to retrieve image data from images snapshot")
|
||||
continue
|
||||
}
|
||||
|
||||
repoTags := imgObject["RepoTags"]
|
||||
if repoTags == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
id := imgObject["Id"]
|
||||
if id == nil {
|
||||
continue
|
||||
}
|
||||
imageId := id.(string)
|
||||
|
||||
repoTagsArray := repoTags.([]interface{})
|
||||
if len(repoTagsArray) > 0 {
|
||||
|
||||
for _, tag := range repoTagsArray {
|
||||
tagName := tag.(string)
|
||||
|
||||
if match(tagName, query) {
|
||||
result := searchResult{
|
||||
Label: tagName,
|
||||
ResultType: "IMAGE",
|
||||
Environment: endpoint.Name,
|
||||
EnvironmentType: envTypeFromEndpoint(endpoint),
|
||||
EnvironmentID: int(endpoint.ID),
|
||||
ResourceID: imageId,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
|
||||
func containerMatch(endpoint *portainer.Endpoint, query string) ([]searchResult, error) {
|
||||
results := make([]searchResult, 0)
|
||||
|
||||
containers, ok := endpoint.Snapshots[0].SnapshotRaw.Containers.([]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Unable to retrieve containers data from snapshot")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
for _, container := range containers {
|
||||
|
||||
cntrObject, ok := container.(map[string]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Unable to retrieve container data from containers snapshot")
|
||||
continue
|
||||
}
|
||||
|
||||
cntrNameEntry := cntrObject["Names"]
|
||||
if cntrNameEntry == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
id := cntrObject["Id"]
|
||||
if id == nil {
|
||||
continue
|
||||
}
|
||||
containerId := id.(string)
|
||||
|
||||
cntrNameArray := cntrNameEntry.([]interface{})
|
||||
if len(cntrNameArray) > 0 {
|
||||
|
||||
containerName := cntrNameArray[0].(string)
|
||||
cName := strings.TrimPrefix(containerName, "/")
|
||||
|
||||
if match(cName, query) {
|
||||
result := searchResult{
|
||||
Label: cName,
|
||||
ResultType: "CONTAINER",
|
||||
Environment: endpoint.Name,
|
||||
EnvironmentType: envTypeFromEndpoint(endpoint),
|
||||
EnvironmentID: int(endpoint.ID),
|
||||
ResourceID: containerId,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func envTypeFromEndpoint(endpoint *portainer.Endpoint) string {
|
||||
switch endpoint.Type {
|
||||
case 1:
|
||||
return "docker"
|
||||
case 2:
|
||||
return "docker"
|
||||
case 3:
|
||||
return "azure"
|
||||
case 4:
|
||||
return "edge"
|
||||
case 5:
|
||||
return "kubernetes"
|
||||
case 6:
|
||||
return "kubernetes"
|
||||
case 7:
|
||||
return "edge"
|
||||
}
|
||||
|
||||
return "unsupported"
|
||||
}
|
||||
|
||||
func match(data, filter string) bool {
|
||||
return strings.Contains(data, filter);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/handler/registries"
|
||||
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
|
||||
"github.com/portainer/portainer/api/http/handler/roles"
|
||||
"github.com/portainer/portainer/api/http/handler/search"
|
||||
"github.com/portainer/portainer/api/http/handler/settings"
|
||||
sslhandler "github.com/portainer/portainer/api/http/handler/ssl"
|
||||
"github.com/portainer/portainer/api/http/handler/stacks"
|
||||
@@ -192,6 +193,9 @@ func (server *Server) Start() error {
|
||||
var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer)
|
||||
resourceControlHandler.DataStore = server.DataStore
|
||||
|
||||
var searchHandler = search.NewHandler(requestBouncer);
|
||||
searchHandler.DataStore = server.DataStore
|
||||
|
||||
var settingsHandler = settings.NewHandler(requestBouncer)
|
||||
settingsHandler.DataStore = server.DataStore
|
||||
settingsHandler.FileService = server.FileService
|
||||
@@ -267,6 +271,7 @@ func (server *Server) Start() error {
|
||||
MOTDHandler: motdHandler,
|
||||
RegistryHandler: registryHandler,
|
||||
ResourceControlHandler: resourceControlHandler,
|
||||
SearchHandler: searchHandler,
|
||||
SettingsHandler: settingsHandler,
|
||||
SSLHandler: sslHandler,
|
||||
StatusHandler: statusHandler,
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
* Header
|
||||
*/
|
||||
.row.header {
|
||||
height: 60px;
|
||||
height: 80px;
|
||||
background: var(--bg-row-header-color);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ angular
|
||||
.constant('API_ENDPOINT_MOTD', 'api/motd')
|
||||
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
|
||||
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
|
||||
.constant('API_ENDPOINT_SEARCH', 'api/search')
|
||||
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
|
||||
.constant('API_ENDPOINT_STACKS', 'api/stacks')
|
||||
.constant('API_ENDPOINT_STATUS', 'api/status')
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
placeholder="Search by name, group, tag, status, URL..."
|
||||
auto-focus
|
||||
data-cy="home-endpointsSearchInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ angular.module('portainer.app').directive('rdHeaderContent', [
|
||||
scope.username = Authentication.getUserDetails().username;
|
||||
},
|
||||
template:
|
||||
'<div class="breadcrumb-links"><div class="pull-left" ng-transclude></div><div class="pull-right" ng-if="username"><a ui-sref="portainer.account" style="margin-right: 5px;"><u><i class="fa fa-wrench" aria-hidden="true"></i> my account </u></a><a ui-sref="portainer.logout({performApiLogout: true})" class="text-danger" style="margin-right: 25px;" data-cy="template-logoutButton"><u><i class="fa fa-sign-out-alt" aria-hidden="true"></i> log out</u></a></div></div>',
|
||||
'<div class="breadcrumb-links"><div class="pull-left" ng-transclude></div><div class="pull-right" ng-if="username"><a ui-sref="portainer.account" style="margin-right: 5px;"><u><i class="fa fa-wrench" aria-hidden="true"></i> my account </u></a><a ui-sref="portainer.logout({performApiLogout: true})" class="text-danger" style="margin-right: 25px;" data-cy="template-logoutButton"><u><i class="fa fa-sign-out-alt" aria-hidden="true"></i> log out</u></a></div></div><search></search>',
|
||||
restrict: 'E',
|
||||
};
|
||||
return directive;
|
||||
|
||||
207
app/portainer/components/search/search.controller.js
Normal file
207
app/portainer/components/search/search.controller.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import autocomplete from 'autocompleter';
|
||||
import './search.css';
|
||||
import searchUIModel from '../../search/models';
|
||||
|
||||
// This is a POC showcasing the ability to search for resources across environments
|
||||
// It leverages snapshots to search across environment resources and exposes a new API over /api/search
|
||||
// It has the following current limitations:
|
||||
// * Redirecting to a resource located inside a Swarm cluster has not been validated (nodeName injection required)
|
||||
// * Redirecting to a resource located inside an Edge environment has not been validated
|
||||
// * Redirecting to an Aure environment has not been validated
|
||||
// * Since Kubernetes environments do not use the snapshot feature, it is not possible to search across k8s resources
|
||||
// * Since Azure ACI environments do not use the snapshot feature, it is not possible to search across ACI resources
|
||||
// * Snapshots are also not supporting Swarm resources, it is not possible to search across Swarm services/configs/secrets
|
||||
// * It is not possible to search across stacks
|
||||
// * The error handling layer and debugging messages in the backend are pretty lightweight
|
||||
// * It uses some CSS hacks with :before to properly display icons in HTML inputs
|
||||
// * It wasn't tested/validated at scale (e.g. a lot of environments/resources)
|
||||
// * Do not apply dark mode / high contrast theme
|
||||
|
||||
// TLDR
|
||||
// * No support for ACI environments
|
||||
// * No support for Kubernetes environments
|
||||
// * No support to search across stacks
|
||||
// * Edge/Swarm+Agent not tested likely to not work
|
||||
// * Not tested at scale
|
||||
// * Do not support dark mode / high contrast themes
|
||||
|
||||
export default class SearchController {
|
||||
/* @ngInject */
|
||||
constructor($state, SearchService, Notifications, EndpointProvider) {
|
||||
this.$state = $state;
|
||||
this.SearchService = SearchService;
|
||||
this.Notifications = Notifications;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
|
||||
this.redirectUserBaseOnSelectedResource = this.redirectUserBaseOnSelectedResource.bind(this);
|
||||
this.changeAppState = this.changeAppState.bind(this);
|
||||
this.search = this.search.bind(this);
|
||||
}
|
||||
|
||||
changeAppState(envId, envType) {
|
||||
if (envType === "azure") {
|
||||
this.$state.go('azure.dashboard', { endpointId: envId });
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: need to properly handle Edge environments redirects
|
||||
// if (envType === "edge") {}
|
||||
|
||||
if (envType === "kubernetes") {
|
||||
this.$state.go('kubernetes.dashboard', { endpointId: envId });
|
||||
return;
|
||||
}
|
||||
|
||||
this.$state.go('docker.dashboard', { endpointId: envId });
|
||||
};
|
||||
|
||||
|
||||
redirectUserBaseOnSelectedResource(resource) {
|
||||
if (resource.group === "ENVIRONMENT") {
|
||||
this.changeAppState(resource.envId, resource.envType);
|
||||
return;
|
||||
}
|
||||
|
||||
this.EndpointProvider.setEndpointID(resource.envId);
|
||||
switch (resource.group) {
|
||||
case "CONTAINER":
|
||||
this.$state.go('docker.containers.container', { endpointId: resource.envId, id: resource.resourceId });
|
||||
break;
|
||||
case "IMAGE":
|
||||
this.$state.go('docker.images.image', { endpointId: resource.envId, id: resource.resourceId });
|
||||
break;
|
||||
case "VOLUME":
|
||||
this.$state.go('docker.volumes.volume', { endpointId: resource.envId, id: resource.resourceId });
|
||||
break;
|
||||
case "NETWORK":
|
||||
this.$state.go('docker.networks.network', { endpointId: resource.envId, id: resource.resourceId });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
search(query, updateCallback) {
|
||||
this.SearchService.search(query).then(function success(data) {
|
||||
if (data.ResultCount) {
|
||||
const transformedData = transformAPItoUIModel(data.Results);
|
||||
updateCallback(transformedData);
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to execute search request');
|
||||
});
|
||||
}
|
||||
|
||||
renderResource(item) {
|
||||
let div = document.createElement("div");
|
||||
div.className = "boxitem";
|
||||
|
||||
let iconSpan = document.createElement("span");
|
||||
iconSpan.className = "boxitem-icon";
|
||||
|
||||
let iconSpanHTML = "";
|
||||
switch (item.group) {
|
||||
case "ENVIRONMENT":
|
||||
switch (item.envType) {
|
||||
case "docker":
|
||||
iconSpanHTML = '<i class="boxitem-icon-ico-docker" aria-hidden="true"></i>';
|
||||
break;
|
||||
case "kubernetes":
|
||||
iconSpanHTML = '<i class="boxitem-icon-ico-kubernetes" aria-hidden="true"></i>';
|
||||
break;
|
||||
case "edge":
|
||||
iconSpanHTML = '<i class="boxitem-icon-ico-edge" aria-hidden="true"></i>';
|
||||
break;
|
||||
default:
|
||||
iconSpanHTML = '<i class="boxitem-icon-ico-generic" aria-hidden="true"></i>';
|
||||
}
|
||||
break;
|
||||
case "CONTAINER":
|
||||
iconSpanHTML = '<i class="boxitem-icon-ico-container" aria-hidden="true"></i>';
|
||||
break;
|
||||
case "IMAGE":
|
||||
iconSpanHTML = '<i class="boxitem-icon-ico-image" aria-hidden="true"></i>';
|
||||
break;
|
||||
case "NETWORK":
|
||||
iconSpanHTML = '<i class="boxitem-icon-ico-network" aria-hidden="true"></i>';
|
||||
break;
|
||||
case "VOLUME":
|
||||
iconSpanHTML = '<i class="boxitem-icon-ico-volume" aria-hidden="true"></i>';
|
||||
break;
|
||||
default:
|
||||
iconSpanHTML = '<i class="boxitem-icon-ico-generic" aria-hidden="true"></i>';
|
||||
}
|
||||
iconSpan.innerHTML = iconSpanHTML;
|
||||
div.appendChild(iconSpan);
|
||||
|
||||
let nameSpan = document.createElement("span");
|
||||
nameSpan.className = "boxitem-name";
|
||||
nameSpan.textContent = item.label;
|
||||
div.appendChild(nameSpan);
|
||||
|
||||
let endpointSpan = document.createElement("span");
|
||||
endpointSpan.className = "boxitem-env";
|
||||
|
||||
let endpointSpanIcon = document.createElement("span");
|
||||
let endpointIconSpanHTML = "";
|
||||
if (item.group != "ENVIRONMENT") {
|
||||
switch (item.envType) {
|
||||
case "docker":
|
||||
endpointIconSpanHTML = '<i class="boxitem-icon-ico-docker" aria-hidden="true"></i>';
|
||||
break;
|
||||
case "kubernetes":
|
||||
endpointIconSpanHTML = '<i class="boxitem-icon-ico-kubernetes" aria-hidden="true"></i>';
|
||||
break;
|
||||
case "edge":
|
||||
endpointIconSpanHTML = '<i class="boxitem-icon-ico-edge" aria-hidden="true"></i>';
|
||||
break;
|
||||
default:
|
||||
endpointIconSpanHTML = '<i class="boxitem-icon-ico-generic" aria-hidden="true"></i>';
|
||||
}
|
||||
}
|
||||
endpointSpanIcon.innerHTML = endpointIconSpanHTML;
|
||||
endpointSpan.appendChild(endpointSpanIcon);
|
||||
|
||||
let endpointSpanName = document.createElement("span");
|
||||
endpointSpanName.className = "boxitem-env-name";
|
||||
endpointSpanName.textContent = item.envName;
|
||||
endpointSpan.appendChild(endpointSpanName);
|
||||
|
||||
div.appendChild(endpointSpan);
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
renderGroup(groupName) {
|
||||
let div = document.createElement("div");
|
||||
div.className = "boxgroup";
|
||||
div.textContent = groupName;
|
||||
return div;
|
||||
}
|
||||
|
||||
initSearchInput() {
|
||||
const input = document.getElementById("resource-search");
|
||||
|
||||
autocomplete({
|
||||
onSelect: this.redirectUserBaseOnSelectedResource,
|
||||
fetch: this.search,
|
||||
input: input,
|
||||
minLength: 2,
|
||||
emptyMsg: 'No resource found',
|
||||
render: this.renderResource,
|
||||
renderGroup: this.renderGroup,
|
||||
debounceWaitMs: 200,
|
||||
});
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.initSearchInput();
|
||||
}
|
||||
}
|
||||
|
||||
function transformAPItoUIModel(data) {
|
||||
var uiModel = data.map(function (item) {
|
||||
return new searchUIModel(item);
|
||||
});
|
||||
|
||||
return uiModel;
|
||||
}
|
||||
107
app/portainer/components/search/search.css
Normal file
107
app/portainer/components/search/search.css
Normal file
@@ -0,0 +1,107 @@
|
||||
.search-bar {
|
||||
width: 98%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.autocomplete .group {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.searchbox {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.boxitem {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.boxgroup {
|
||||
margin-left: 2px;
|
||||
margin-bottom: 5px;
|
||||
color: #767676;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.boxitem-icon {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.boxitem-name {
|
||||
margin-left: 25px;
|
||||
}
|
||||
|
||||
.boxitem-icon-ico-container:before {
|
||||
position: absolute;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
content: '\f1b3';
|
||||
color: #286090;
|
||||
}
|
||||
|
||||
.boxitem-icon-ico-image:before {
|
||||
position: absolute;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
content: '\f24d';
|
||||
color: #286090;
|
||||
}
|
||||
|
||||
.boxitem-icon-ico-volume:before {
|
||||
position: absolute;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
content: '\f0a0';
|
||||
color: #286090;
|
||||
}
|
||||
|
||||
.boxitem-icon-ico-network:before {
|
||||
position: absolute;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
content: '\f0e8';
|
||||
color: #286090;
|
||||
}
|
||||
|
||||
|
||||
.boxitem-icon-ico-docker:before {
|
||||
position: absolute;
|
||||
font-family: 'Font Awesome 5 Brands';
|
||||
font-weight: 900;
|
||||
content: '\f395';
|
||||
color: #286090;
|
||||
}
|
||||
|
||||
.boxitem-icon-ico-kubernetes:before {
|
||||
position: absolute;
|
||||
font-family: 'Font Awesome 5 Brands';
|
||||
font-weight: 900;
|
||||
content: '\f655';
|
||||
color: #286090;
|
||||
}
|
||||
|
||||
.boxitem-icon-ico-edge:before {
|
||||
position: absolute;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
content: '\f0c2';
|
||||
color: #286090;
|
||||
}
|
||||
|
||||
.boxitem-icon-ico-generic:before {
|
||||
position: absolute;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-weight: 900;
|
||||
content: '\f128';
|
||||
color: #286090;
|
||||
}
|
||||
|
||||
.boxitem-env {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.boxitem-env-name {
|
||||
margin-left: 25px;
|
||||
}
|
||||
3
app/portainer/components/search/search.html
Normal file
3
app/portainer/components/search/search.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="search-bar">
|
||||
<input id="resource-search" type="search" class="searchbox" spellcheck=false autocorrect="off" autocomplete="off" autocapitalize="off" placeholder="Search by resource name">
|
||||
</div>
|
||||
7
app/portainer/components/search/search.js
Normal file
7
app/portainer/components/search/search.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import angular from 'angular';
|
||||
import controller from './search.controller';
|
||||
|
||||
angular.module('portainer.app').component('search', {
|
||||
templateUrl: './search.html',
|
||||
controller,
|
||||
});
|
||||
11
app/portainer/search/models.js
Normal file
11
app/portainer/search/models.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export default class searchUIModel {
|
||||
constructor(data) {
|
||||
this.label = data.Label;
|
||||
this.group = data.Type;
|
||||
this.value = "";
|
||||
this.envName = data.Environment;
|
||||
this.envType = data.EnvironmentType;
|
||||
this.envId = data.EnvironmentID;
|
||||
this.resourceId = data.ResourceID;
|
||||
}
|
||||
}
|
||||
20
app/portainer/search/rest.js
Normal file
20
app/portainer/search/rest.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import angular from 'angular';
|
||||
|
||||
angular.module('portainer.kubernetes').factory('SearchFactory', SearchFactory);
|
||||
|
||||
/* @ngInject */
|
||||
function SearchFactory($resource, API_ENDPOINT_SEARCH) {
|
||||
const searchUrl = API_ENDPOINT_SEARCH;
|
||||
|
||||
return $resource(
|
||||
searchUrl,
|
||||
{},
|
||||
{
|
||||
search: {
|
||||
method: 'GET',
|
||||
url: `${searchUrl}`,
|
||||
params: { query: '@query' },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
96
app/portainer/search/service.js
Normal file
96
app/portainer/search/service.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import angular from 'angular';
|
||||
import PortainerError from 'Portainer/error';
|
||||
|
||||
angular.module('portainer.app').factory('SearchService', SearchService);
|
||||
|
||||
/* @ngInject */
|
||||
export function SearchService(SearchFactory) {
|
||||
return {
|
||||
search,
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: Searches for resources inside a Portainer instance
|
||||
* @param {string} query - query keyword used to search/filter resources
|
||||
* @returns {Promise} - Resolves with a list of resources matching the query keyword
|
||||
* @throws {PortainerError} - Rejects with error if searching for resources across environments fails
|
||||
*/
|
||||
async function search(query) {
|
||||
try {
|
||||
return await SearchFactory.search({ query }).$promise;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to execute search request', err);
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @description: Show values helm of a helm chart, this basically runs `helm show values`
|
||||
// * @param {string} repo - repo url to search charts values for
|
||||
// * @param {string} chart - chart within the repo to retrieve default values
|
||||
// * @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo
|
||||
// * @throws {PortainerError} - Rejects with error if helm show fails
|
||||
// */
|
||||
// async function values(repo, chart) {
|
||||
// try {
|
||||
// return await HelmFactory.show({ repo, chart, type: 'values' }).$promise;
|
||||
// } catch (err) {
|
||||
// throw new PortainerError('Unable to retrieve values from chart', err);
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * @description: Show values helm of a helm chart, this basically runs `helm show values`
|
||||
// * @returns {Promise} - Resolves with an object containing list of user helm repos and default/global settings helm repo
|
||||
// * @throws {PortainerError} - Rejects with error if helm show fails
|
||||
// */
|
||||
// async function getHelmRepositories(endpointId) {
|
||||
// return await HelmFactory.getHelmRepositories({ endpointId }).$promise;
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * @description: Adds a helm repo for the calling user
|
||||
// * @param {Object} payload - helm repo url to add for the user
|
||||
// * @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo
|
||||
// * @throws {PortainerError} - Rejects with error if helm show fails
|
||||
// */
|
||||
// async function addHelmRepository(endpointId, payload) {
|
||||
// return await HelmFactory.addHelmRepository({ endpointId }, payload).$promise;
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * @description: Installs a helm chart, this basically runs `helm install`
|
||||
// * @returns {Promise} - Resolves with `values.yaml` of helm chart values for a repo
|
||||
// * @throws {PortainerError} - Rejects with error if helm show fails
|
||||
// */
|
||||
// async function install(endpointId, payload) {
|
||||
// return await HelmFactory.install({ endpointId }, payload).$promise;
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * @description: Uninstall a helm chart, this basically runs `helm uninstall`
|
||||
// * @param {Object} options - Options object, release `Name` is the only required option
|
||||
// * @throws {PortainerError} - Rejects with error if helm show fails
|
||||
// */
|
||||
// async function uninstall(endpointId, { Name, ResourcePool }) {
|
||||
// try {
|
||||
// await HelmFactory.uninstall({ endpointId, release: Name, namespace: ResourcePool }).$promise;
|
||||
// } catch (err) {
|
||||
// throw new PortainerError('Unable to delete release', err);
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * @description: List all helm releases based on passed in options, this basically runs `helm list`
|
||||
// * @param {Object} options - Supported CLI flags to pass to Helm (binary) - flags to `helm list`
|
||||
// * @returns {Promise} - Resolves with list of helm releases
|
||||
// * @throws {PortainerError} - Rejects with error if helm list fails
|
||||
// */
|
||||
// async function listReleases(endpointId, { namespace, selector, filter, output }) {
|
||||
// try {
|
||||
// const releases = await HelmFactory.list({ endpointId, selector, namespace, filter, output }).$promise;
|
||||
// return releases;
|
||||
// } catch (err) {
|
||||
// throw new PortainerError('Unable to retrieve release list', err);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import 'angular-loading-bar/build/loading-bar.css';
|
||||
import 'angular-moment-picker/dist/angular-moment-picker.min.css';
|
||||
import 'angular-multiselect/isteven-multi-select.css';
|
||||
import 'spinkit/spinkit.min.css';
|
||||
import 'autocompleter/autocomplete.min.css'
|
||||
|
||||
import angular from 'angular';
|
||||
import 'moment';
|
||||
@@ -37,5 +38,6 @@ import 'angular-ui-bootstrap';
|
||||
import 'angular-moment-picker';
|
||||
import 'angular-multiselect/isteven-multi-select.js';
|
||||
import 'angulartics/dist/angulartics.min.js';
|
||||
import 'autocompleter/autocomplete.min.js'
|
||||
|
||||
window.angular = angular;
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"angularjs-scroll-glue": "^2.2.0",
|
||||
"angularjs-slider": "^6.4.0",
|
||||
"angulartics": "^1.6.0",
|
||||
"autocompleter": "^6.1.2",
|
||||
"babel-plugin-angularjs-annotate": "^0.10.0",
|
||||
"bootbox": "^5.4.0",
|
||||
"bootstrap": "^3.4.0",
|
||||
|
||||
11
yarn.lock
11
yarn.lock
@@ -1397,7 +1397,6 @@ angular-moment-picker@^0.10.2:
|
||||
dependencies:
|
||||
angular-mocks "1.6.1"
|
||||
angular-sanitize "1.6.1"
|
||||
lodash-es "^4.17.15"
|
||||
|
||||
angular-resource@1.8.0:
|
||||
version "1.8.0"
|
||||
@@ -1756,6 +1755,11 @@ auto-ngtemplate-loader@^2.0.1:
|
||||
ngtemplate-loader "~2.0.1"
|
||||
var-validator "0.0.3"
|
||||
|
||||
autocompleter@^6.1.2:
|
||||
version "6.1.2"
|
||||
resolved "https://registry.yarnpkg.com/autocompleter/-/autocompleter-6.1.2.tgz#35a2c8b496c1ebdd798183e26c529e4b7450fd34"
|
||||
integrity sha512-DfEcgxBJOTJJwxkIRZLv/ggD3g5w/fqzZkdJsOcgk7hjxw36lH/nAfIEXzV7qDE55swnYEe43E/WhZPXmSFfsA==
|
||||
|
||||
autoprefixer@^7.1.1:
|
||||
version "7.2.6"
|
||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-7.2.6.tgz#256672f86f7c735da849c4f07d008abb056067dc"
|
||||
@@ -6982,11 +6986,6 @@ locate-path@^5.0.0:
|
||||
dependencies:
|
||||
p-locate "^4.1.0"
|
||||
|
||||
lodash-es@^4.17.15:
|
||||
version "4.17.15"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
|
||||
integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==
|
||||
|
||||
lodash-es@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
|
||||
|
||||
Reference in New Issue
Block a user