diff --git a/internal/api/rest.go b/internal/api/rest.go index 712a0b3..0fa4611 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -69,11 +69,15 @@ func New() *RestApi { func (api *RestApi) MountApiRoutes(r *mux.Router) { r.StrictSlash(true) - + // REST API Uses TokenAuth + // User List + r.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) + // Cluster List + r.HandleFunc("/clusters/", api.getClusters).Methods(http.MethodGet) + // Job Handler r.HandleFunc("/jobs/start_job/", api.startJob).Methods(http.MethodPost, http.MethodPut) r.HandleFunc("/jobs/stop_job/", api.stopJobByRequest).Methods(http.MethodPost, http.MethodPut) // r.HandleFunc("/jobs/import/", api.importJob).Methods(http.MethodPost, http.MethodPut) - r.HandleFunc("/jobs/", api.getJobs).Methods(http.MethodGet) r.HandleFunc("/jobs/{id}", api.getJobById).Methods(http.MethodPost) r.HandleFunc("/jobs/{id}", api.getCompleteJobById).Methods(http.MethodGet) @@ -84,8 +88,6 @@ func (api *RestApi) MountApiRoutes(r *mux.Router) { r.HandleFunc("/jobs/delete_job/{id}", api.deleteJobById).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job_before/{ts}", api.deleteJobBefore).Methods(http.MethodDelete) - r.HandleFunc("/clusters/", api.getClusters).Methods(http.MethodGet) - if api.MachineStateDir != "" { r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet) r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost) @@ -94,7 +96,7 @@ func (api *RestApi) MountApiRoutes(r *mux.Router) { func (api *RestApi) MountUserApiRoutes(r *mux.Router) { r.StrictSlash(true) - + // REST API Uses TokenAuth r.HandleFunc("/jobs/", api.getJobs).Methods(http.MethodGet) r.HandleFunc("/jobs/{id}", api.getJobById).Methods(http.MethodPost) r.HandleFunc("/jobs/{id}", api.getCompleteJobById).Methods(http.MethodGet) @@ -103,7 +105,7 @@ func (api *RestApi) MountUserApiRoutes(r *mux.Router) { func (api *RestApi) MountConfigApiRoutes(r *mux.Router) { r.StrictSlash(true) - + // Settings Frontend Uses SessionAuth if api.Authentication != nil { r.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) r.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) @@ -116,7 +118,7 @@ func (api *RestApi) MountConfigApiRoutes(r *mux.Router) { func (api *RestApi) MountFrontendApiRoutes(r *mux.Router) { r.StrictSlash(true) - + // Settings Frontrend Uses SessionAuth if api.Authentication != nil { r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) @@ -221,44 +223,6 @@ func decode(r io.Reader, val interface{}) error { return dec.Decode(val) } -func securedCheck(r *http.Request) error { - user := repository.GetUserFromContext(r.Context()) - if user == nil { - return fmt.Errorf("no user in context") - } - - if user.AuthType == schema.AuthToken { - // If nothing declared in config: deny all request to this endpoint - if config.Keys.ApiAllowedIPs == nil || len(config.Keys.ApiAllowedIPs) == 0 { - return fmt.Errorf("missing configuration key ApiAllowedIPs") - } - - if config.Keys.ApiAllowedIPs[0] == "*" { - return nil - } - - // extract IP address - IPAddress := r.Header.Get("X-Real-Ip") - if IPAddress == "" { - IPAddress = r.Header.Get("X-Forwarded-For") - } - if IPAddress == "" { - IPAddress = r.RemoteAddr - } - - if strings.Contains(IPAddress, ":") { - IPAddress = strings.Split(IPAddress, ":")[0] - } - - // check if IP is allowed - if !util.Contains(config.Keys.ApiAllowedIPs, IPAddress) { - return fmt.Errorf("unknown ip: %v", IPAddress) - } - } - - return nil -} - // getClusters godoc // @summary Lists all cluster configs // @tags Cluster query @@ -1093,7 +1057,6 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) { // @summary Adds a new user // @tags User // @description User specified in form data will be saved to database. -// @description Only accessible from IPs registered with apiAllowedIPs configuration option. // @accept mpfd // @produce plain // @param username formData string true "Unique user ID" @@ -1111,11 +1074,7 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /users/ [post] func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { - err := securedCheck(r) - if err != nil { - http.Error(rw, err.Error(), http.StatusForbidden) - return - } + // SecuredCheck() only worked with TokenAuth: Removed rw.Header().Set("Content-Type", "text/plain") me := repository.GetUserFromContext(r.Context()) @@ -1162,7 +1121,6 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { // @summary Deletes a user // @tags User // @description User defined by username in form data will be deleted from database. -// @description Only accessible from IPs registered with apiAllowedIPs configuration option. // @accept mpfd // @produce plain // @param username formData string true "User ID to delete" @@ -1175,11 +1133,7 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /users/ [delete] func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { - err := securedCheck(r) - if err != nil { - http.Error(rw, err.Error(), http.StatusForbidden) - return - } + // SecuredCheck() only worked with TokenAuth: Removed if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { http.Error(rw, "Only admins are allowed to delete a user", http.StatusForbidden) @@ -1200,7 +1154,6 @@ func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { // @tags User // @description Returns a JSON-encoded list of users. // @description Required query-parameter defines if all users or only users with additional special roles are returned. -// @description Only accessible from IPs registered with apiAllowedIPs configuration option. // @produce json // @param not-just-user query bool true "If returned list should contain all users or only users with additional special roles" // @success 200 {array} api.ApiReturnedUser "List of users returned successfully" @@ -1211,11 +1164,7 @@ func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /users/ [get] func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { - err := securedCheck(r) - if err != nil { - http.Error(rw, err.Error(), http.StatusForbidden) - return - } + // SecuredCheck() only worked with TokenAuth: Removed if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { http.Error(rw, "Only admins are allowed to fetch a list of users", http.StatusForbidden) @@ -1236,7 +1185,6 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { // @tags User // @description Modifies user defined by username (id) in one of four possible ways. // @description If more than one formValue is set then only the highest priority field is used. -// @description Only accessible from IPs registered with apiAllowedIPs configuration option. // @accept mpfd // @produce plain // @param id path string true "Database ID of User" @@ -1253,11 +1201,7 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /user/{id} [post] func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { - err := securedCheck(r) - if err != nil { - http.Error(rw, err.Error(), http.StatusForbidden) - return - } + // SecuredCheck() only worked with TokenAuth: Removed if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { http.Error(rw, "Only admins are allowed to update a user", http.StatusForbidden) @@ -1305,7 +1249,6 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { // @tags User // @description Modifies the content of notice.txt, shown as notice box on the homepage. // @description If more than one formValue is set then only the highest priority field is used. -// @description Only accessible from IPs registered with apiAllowedIPs configuration option. // @accept mpfd // @produce plain // @param new-content formData string false "Priority 1: New content to display" @@ -1318,11 +1261,7 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /notice/ [post] func (api *RestApi) editNotice(rw http.ResponseWriter, r *http.Request) { - err := securedCheck(r) - if err != nil { - http.Error(rw, err.Error(), http.StatusForbidden) - return - } + // SecuredCheck() only worked with TokenAuth: Removed if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { http.Error(rw, "Only admins are allowed to update the notice.txt file", http.StatusForbidden) @@ -1364,12 +1303,6 @@ func (api *RestApi) editNotice(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { - err := securedCheck(r) - if err != nil { - http.Error(rw, err.Error(), http.StatusForbidden) - return - } - rw.Header().Set("Content-Type", "text/plain") username := r.FormValue("username") me := repository.GetUserFromContext(r.Context()) @@ -1398,11 +1331,7 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) { - err := securedCheck(r) - if err != nil { - http.Error(rw, err.Error(), http.StatusForbidden) - return - } + // SecuredCheck() only worked with TokenAuth: Removed user := repository.GetUserFromContext(r.Context()) if !user.HasRole(schema.RoleAdmin) { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 262204c..9201315 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -10,9 +10,11 @@ import ( "database/sql" "encoding/base64" "errors" + "fmt" "net" "net/http" "os" + "strings" "sync" "time" @@ -20,6 +22,7 @@ import ( "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/repository" + "github.com/ClusterCockpit/cc-backend/internal/util" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/gorilla/sessions" @@ -233,9 +236,9 @@ func (auth *Authentication) Login( limiter := getIPUserLimiter(ip, username) if !limiter.Allow() { - log.Warnf("AUTH/RATE > Too many login attempts for combination IP: %s, Username: %s", ip, username) - onfailure(rw, r, errors.New("Too many login attempts, try again in a few minutes.")) - return + log.Warnf("AUTH/RATE > Too many login attempts for combination IP: %s, Username: %s", ip, username) + onfailure(rw, r, errors.New("Too many login attempts, try again in a few minutes.")) + return } var dbUser *schema.User @@ -325,6 +328,14 @@ func (auth *Authentication) AuthApi( onfailure(rw, r, err) return } + + ipErr := securedCheck(user, r) + if ipErr != nil { + log.Infof("auth api -> secured check failed: %s", err.Error()) + onfailure(rw, r, ipErr) + return + } + if user != nil { switch { case len(user.Roles) == 1: @@ -360,6 +371,7 @@ func (auth *Authentication) AuthUserApi( onfailure(rw, r, err) return } + if user != nil { switch { case len(user.Roles) == 1: @@ -445,3 +457,38 @@ func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { onsuccess.ServeHTTP(rw, r) }) } + +// Helper Moved To MiddleWare Auth Handlers +func securedCheck(user *schema.User, r *http.Request) error { + if user == nil { + return fmt.Errorf("no user for secured check") + } + + // extract IP address for checking + IPAddress := r.Header.Get("X-Real-Ip") + if IPAddress == "" { + IPAddress = r.Header.Get("X-Forwarded-For") + } + if IPAddress == "" { + IPAddress = r.RemoteAddr + } + + if strings.Contains(IPAddress, ":") { + IPAddress = strings.Split(IPAddress, ":")[0] + } + + // If nothing declared in config: deny all request to this api endpoint + if len(config.Keys.ApiAllowedIPs) == 0 { + return fmt.Errorf("missing configuration key ApiAllowedIPs") + } + // If wildcard declared in config: Continue + if config.Keys.ApiAllowedIPs[0] == "*" { + return nil + } + // check if IP is allowed + if !util.Contains(config.Keys.ApiAllowedIPs, IPAddress) { + return fmt.Errorf("unknown ip: %v", IPAddress) + } + + return nil +} diff --git a/internal/repository/jobFind.go b/internal/repository/jobFind.go index 0354df0..1e2ccb8 100644 --- a/internal/repository/jobFind.go +++ b/internal/repository/jobFind.go @@ -194,11 +194,13 @@ func (r *JobRepository) FindConcurrentJobs( queryRunning := query.Where("job.job_state = ?").Where("(job.start_time BETWEEN ? AND ? OR job.start_time < ?)", "running", startTimeTail, stopTimeTail, startTime) - queryRunning = queryRunning.Where("job.resources LIKE ?", fmt.Sprint("%", hostname, "%")) + // Get At Least One Exact Hostname Match from JSON Resources Array in Database + queryRunning = queryRunning.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, '$.hostname') = ?)", hostname) query = query.Where("job.job_state != ?").Where("((job.start_time BETWEEN ? AND ?) OR (job.start_time + job.duration) BETWEEN ? AND ? OR (job.start_time < ?) AND (job.start_time + job.duration) > ?)", "running", startTimeTail, stopTimeTail, startTimeFront, stopTimeTail, startTime, stopTime) - query = query.Where("job.resources LIKE ?", fmt.Sprint("%", hostname, "%")) + // Get At Least One Exact Hostname Match from JSON Resources Array in Database + query = query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, '$.hostname') = ?)", hostname) rows, err := query.RunWith(r.stmtCache).Query() if err != nil { diff --git a/internal/repository/jobQuery.go b/internal/repository/jobQuery.go index f784445..d169b6f 100644 --- a/internal/repository/jobQuery.go +++ b/internal/repository/jobQuery.go @@ -67,7 +67,8 @@ func (r *JobRepository) QueryJobs( rows, err := query.RunWith(r.stmtCache).Query() if err != nil { - log.Errorf("Error while running query: %v", err) + queryString, queryVars, _ := query.ToSql() + log.Errorf("Error while running query '%s' %v: %v", queryString, queryVars, err) return nil, err } @@ -197,7 +198,7 @@ func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.Select query = buildIntCondition("job.num_hwthreads", filter.NumHWThreads, query) } if filter.Node != nil { - query = buildStringCondition("job.resources", filter.Node, query) + query = buildResourceJsonCondition("hostname", filter.Node, query) } if filter.Energy != nil { query = buildFloatCondition("job.energy", filter.Energy, query) @@ -299,6 +300,28 @@ func buildMetaJsonCondition(jsonField string, cond *model.StringInput, query sq. return query } +func buildResourceJsonCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder { + // Verify and Search Only in Valid Jsons + query = query.Where("JSON_VALID(resources)") + // add "AND" Sql query Block for field match + if cond.Eq != nil { + return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") = ?)", *cond.Eq) + } + if cond.Neq != nil { // Currently Unused + return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") != ?)", *cond.Neq) + } + if cond.StartsWith != nil { // Currently Unused + return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\")) LIKE ?)", fmt.Sprint(*cond.StartsWith, "%")) + } + if cond.EndsWith != nil { // Currently Unused + return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.EndsWith)) + } + if cond.Contains != nil { + return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.Contains, "%")) + } + return query +} + var ( matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") diff --git a/pkg/schema/config.go b/pkg/schema/config.go index f9116cf..27d11be 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -100,7 +100,7 @@ type ProgramConfig struct { // Address where the http (or https) server will listen on (for example: 'localhost:80'). Addr string `json:"addr"` - // Addresses from which secured API endpoints can be reached + // Addresses from which secured admin API endpoints can be reached, can be wildcard "*" ApiAllowedIPs []string `json:"apiAllowedIPs"` // Drop root permissions once .env was read and the port was taken. diff --git a/web/frontend/src/generic/Filters.svelte b/web/frontend/src/generic/Filters.svelte index 4a9be3e..74640ae 100644 --- a/web/frontend/src/generic/Filters.svelte +++ b/web/frontend/src/generic/Filters.svelte @@ -53,10 +53,16 @@ { range: "last30d", rangeLabel: "Last 30 days"} ]; + const nodeMatchLabels = { + eq: "", + contains: " Contains", + } + let filters = { projectMatch: filterPresets.projectMatch || "contains", userMatch: filterPresets.userMatch || "contains", jobIdMatch: filterPresets.jobIdMatch || "eq", + nodeMatch: filterPresets.nodeMatch || "eq", cluster: filterPresets.cluster || null, partition: filterPresets.partition || null, @@ -106,7 +112,7 @@ let items = []; if (filters.cluster) items.push({ cluster: { eq: filters.cluster } }); - if (filters.node) items.push({ node: { contains: filters.node } }); + if (filters.node) items.push({ node: { [filters.nodeMatch]: filters.node } }); if (filters.partition) items.push({ partition: { eq: filters.partition } }); if (filters.states.length != allJobStates.length) items.push({ state: filters.states }); @@ -178,6 +184,8 @@ let opts = []; if (filters.cluster) opts.push(`cluster=${filters.cluster}`); if (filters.node) opts.push(`node=${filters.node}`); + if (filters.node && filters.nodeMatch != "eq") // "eq" is default-case + opts.push(`nodeMatch=${filters.nodeMatch}`); if (filters.partition) opts.push(`partition=${filters.partition}`); if (filters.states.length != allJobStates.length) for (let state of filters.states) opts.push(`state=${state}`); @@ -196,7 +204,7 @@ opts.push(`jobId=${singleJobId}`); } if (filters.jobIdMatch != "eq") - opts.push(`jobIdMatch=${filters.jobIdMatch}`); + opts.push(`jobIdMatch=${filters.jobIdMatch}`); // "eq" is default-case for (let tag of filters.tags) opts.push(`tag=${tag}`); if (filters.duration.from && filters.duration.to) opts.push(`duration=${filters.duration.from}-${filters.duration.to}`); @@ -218,13 +226,13 @@ } else { for (let singleUser of filters.user) opts.push(`user=${singleUser}`); } - if (filters.userMatch != "contains") + if (filters.userMatch != "contains") // "contains" is default-case opts.push(`userMatch=${filters.userMatch}`); if (filters.project) opts.push(`project=${filters.project}`); + if (filters.project && filters.projectMatch != "contains") // "contains" is default-case + opts.push(`projectMatch=${filters.projectMatch}`); if (filters.jobName) opts.push(`jobName=${filters.jobName}`); if (filters.arrayJobId) opts.push(`arrayJobId=${filters.arrayJobId}`); - if (filters.project && filters.projectMatch != "contains") - opts.push(`projectMatch=${filters.projectMatch}`); if (filters.stats.length != 0) for (let stat of filters.stats) { opts.push(`stat=${stat.field}-${stat.from}-${stat.to}`); @@ -386,7 +394,7 @@ {#if filters.node != null} (isResourcesOpen = true)}> - Node: {filters.node} + Node{nodeMatchLabels[filters.nodeMatch]}: {filters.node} {/if} @@ -449,6 +457,7 @@ bind:numHWThreads={filters.numHWThreads} bind:numAccelerators={filters.numAccelerators} bind:namedNode={filters.node} + bind:nodeMatch={filters.nodeMatch} bind:isNodesModified bind:isHwthreadsModified bind:isAccsModified diff --git a/web/frontend/src/generic/filters/Resources.svelte b/web/frontend/src/generic/filters/Resources.svelte index 750c4a6..443dda7 100644 --- a/web/frontend/src/generic/filters/Resources.svelte +++ b/web/frontend/src/generic/filters/Resources.svelte @@ -24,6 +24,7 @@ ModalBody, ModalHeader, ModalFooter, + Input } from "@sveltestrap/sveltestrap"; import DoubleRangeSlider from "../select/DoubleRangeSlider.svelte"; @@ -40,11 +41,18 @@ export let isHwthreadsModified = false; export let isAccsModified = false; export let namedNode = null; + export let nodeMatch = "eq" let pendingNumNodes = numNodes, pendingNumHWThreads = numHWThreads, pendingNumAccelerators = numAccelerators, - pendingNamedNode = namedNode; + pendingNamedNode = namedNode, + pendingNodeMatch = nodeMatch; + + const nodeMatchLabels = { + eq: "Equal To", + contains: "Contains", + } const findMaxNumAccels = (clusters) => clusters.reduce( @@ -145,7 +153,17 @@ Select number of utilized Resources
Named Node
- +
+ +
+ + {#each Object.entries(nodeMatchLabels) as [nodeMatchKey, nodeMatchLabel]} + + {/each} + +
Number of Nodes
{ @@ -215,11 +233,13 @@ to: pendingNumAccelerators.to, }; namedNode = pendingNamedNode; + nodeMatch = pendingNodeMatch; dispatch("set-filter", { numNodes, numHWThreads, numAccelerators, namedNode, + nodeMatch }); }} > @@ -233,6 +253,7 @@ pendingNumHWThreads = { from: null, to: null }; pendingNumAccelerators = { from: null, to: null }; pendingNamedNode = null; + pendingNodeMatch = null; numNodes = { from: pendingNumNodes.from, to: pendingNumNodes.to }; numHWThreads = { from: pendingNumHWThreads.from, @@ -246,11 +267,13 @@ isHwthreadsModified = false; isAccsModified = false; namedNode = pendingNamedNode; + nodeMatch = pendingNodeMatch; dispatch("set-filter", { numNodes, numHWThreads, numAccelerators, namedNode, + nodeMatch }); }}>Reset diff --git a/web/frontend/src/job/statstab/StatsTableEntry.svelte b/web/frontend/src/job/statstab/StatsTableEntry.svelte index b39eacb..c3161a5 100644 --- a/web/frontend/src/job/statstab/StatsTableEntry.svelte +++ b/web/frontend/src/job/statstab/StatsTableEntry.svelte @@ -41,7 +41,9 @@ if (a == null || b == null) return -1; if (field === "id") { - return s.dir != "up" ? a[field].localeCompare(b[field]) : b[field].localeCompare(a[field]) + return s.dir != "up" ? + a[field].localeCompare(b[field], undefined, {numeric: true, sensitivity: 'base'}) : + b[field].localeCompare(a[field], undefined, {numeric: true, sensitivity: 'base'}) } else { return s.dir != "up" ? a.data[field] - b.data[field] diff --git a/web/frontend/src/systems/NodeList.svelte b/web/frontend/src/systems/NodeList.svelte index ca22d57..c8a5e51 100644 --- a/web/frontend/src/systems/NodeList.svelte +++ b/web/frontend/src/systems/NodeList.svelte @@ -205,7 +205,7 @@ {:else} - {#each nodes as nodeData} + {#each nodes as nodeData (nodeData.host)} {:else}