mirror of
				https://github.com/ClusterCockpit/cc-backend
				synced 2025-10-30 23:45:06 +01:00 
			
		
		
		
	Split systems view into node-overview and node-list
This commit is contained in:
		| @@ -42,10 +42,11 @@ var routes []Route = []Route{ | |||||||
| 	{"/monitoring/projects/", "monitoring/list.tmpl", "Projects - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "PROJECT"; return i }}, | 	{"/monitoring/projects/", "monitoring/list.tmpl", "Projects - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "PROJECT"; return i }}, | ||||||
| 	{"/monitoring/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute}, | 	{"/monitoring/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute}, | ||||||
| 	{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute}, | 	{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute}, | ||||||
| 	{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> - ClusterCockpit", false, setupClusterRoute}, | 	{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Overview - ClusterCockpit", false, setupClusterOverviewRoute}, | ||||||
|  | 	{"/monitoring/systems/list/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> List - ClusterCockpit", false, setupClusterListRoute}, | ||||||
| 	{"/monitoring/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute}, | 	{"/monitoring/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute}, | ||||||
| 	{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute}, | 	{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute}, | ||||||
| 	{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterRoute}, | 	{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterStatusRoute}, | ||||||
| } | } | ||||||
|  |  | ||||||
| func setupHomeRoute(i InfoType, r *http.Request) InfoType { | func setupHomeRoute(i InfoType, r *http.Request) InfoType { | ||||||
| @@ -96,7 +97,7 @@ func setupUserRoute(i InfoType, r *http.Request) InfoType { | |||||||
| 	return i | 	return i | ||||||
| } | } | ||||||
|  |  | ||||||
| func setupClusterRoute(i InfoType, r *http.Request) InfoType { | func setupClusterStatusRoute(i InfoType, r *http.Request) InfoType { | ||||||
| 	vars := mux.Vars(r) | 	vars := mux.Vars(r) | ||||||
| 	i["id"] = vars["cluster"] | 	i["id"] = vars["cluster"] | ||||||
| 	i["cluster"] = vars["cluster"] | 	i["cluster"] = vars["cluster"] | ||||||
| @@ -108,6 +109,34 @@ func setupClusterRoute(i InfoType, r *http.Request) InfoType { | |||||||
| 	return i | 	return i | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func setupClusterOverviewRoute(i InfoType, r *http.Request) InfoType { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	i["id"] = vars["cluster"] | ||||||
|  | 	i["cluster"] = vars["cluster"] | ||||||
|  | 	i["displayType"] = "OVERVIEW" | ||||||
|  |  | ||||||
|  | 	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to") | ||||||
|  | 	if from != "" || to != "" { | ||||||
|  | 		i["from"] = from | ||||||
|  | 		i["to"] = to | ||||||
|  | 	} | ||||||
|  | 	return i | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func setupClusterListRoute(i InfoType, r *http.Request) InfoType { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	i["id"] = vars["cluster"] | ||||||
|  | 	i["cluster"] = vars["cluster"] | ||||||
|  | 	i["displayType"] = "LIST" | ||||||
|  |  | ||||||
|  | 	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to") | ||||||
|  | 	if from != "" || to != "" { | ||||||
|  | 		i["from"] = from | ||||||
|  | 		i["to"] = to | ||||||
|  | 	} | ||||||
|  | 	return i | ||||||
|  | } | ||||||
|  |  | ||||||
| func setupNodeRoute(i InfoType, r *http.Request) InfoType { | func setupNodeRoute(i InfoType, r *http.Request) InfoType { | ||||||
| 	vars := mux.Vars(r) | 	vars := mux.Vars(r) | ||||||
| 	i["cluster"] = vars["cluster"] | 	i["cluster"] = vars["cluster"] | ||||||
|   | |||||||
| @@ -97,7 +97,7 @@ | |||||||
|       href: "/monitoring/systems/", |       href: "/monitoring/systems/", | ||||||
|       icon: "hdd-rack", |       icon: "hdd-rack", | ||||||
|       perCluster: true, |       perCluster: true, | ||||||
|       listOptions: false, |       listOptions: true, | ||||||
|       menu: "Info", |       menu: "Info", | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -1,232 +1,44 @@ | |||||||
| <!-- | <!-- | ||||||
|     @component Main cluster metric status view component; renders current state of metrics / nodes |     @component Main cluster node status view component; renders overview or list depending on type | ||||||
|  |  | ||||||
|     Properties: |     Properties: | ||||||
|  |     - `displayType String?`: The type of node display ['OVERVIEW' || 'LIST'] | ||||||
|     - `cluster String`: The cluster to show status information for |     - `cluster String`: The cluster to show status information for | ||||||
|     - `from Date?`: Custom Time Range selection 'from' [Default: null] |     - `from Date?`: Custom Time Range selection 'from' [Default: null] | ||||||
|     - `to Date?`: Custom Time Range selection 'to' [Default: null] |     - `to Date?`: Custom Time Range selection 'to' [Default: null] | ||||||
|  --> |  --> | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
|   import { getContext } from "svelte"; |  | ||||||
|   import { |   import { | ||||||
|     Row, |     Row, | ||||||
|     Col, |     Col, | ||||||
|     Input, |  | ||||||
|     InputGroup, |  | ||||||
|     InputGroupText, |  | ||||||
|     Icon, |  | ||||||
|     Spinner, |  | ||||||
|     Card, |     Card, | ||||||
|   } from "@sveltestrap/sveltestrap"; |   } from "@sveltestrap/sveltestrap"; | ||||||
|   import { |  | ||||||
|     queryStore, |  | ||||||
|     gql, |  | ||||||
|     getContextClient, |  | ||||||
|   } from "@urql/svelte"; |  | ||||||
|   import { |  | ||||||
|     init, |  | ||||||
|     checkMetricDisabled, |  | ||||||
|   } from "./generic/utils.js"; |  | ||||||
|   import PlotGrid from "./generic/PlotGrid.svelte"; |  | ||||||
|   import MetricPlot from "./generic/plots/MetricPlot.svelte"; |  | ||||||
|   import TimeSelection from "./generic/select/TimeSelection.svelte"; |  | ||||||
|   import Refresher from "./generic/helper/Refresher.svelte"; |  | ||||||
|  |  | ||||||
|  |   import NodeOverview from "./systems/NodeOverview.svelte"; | ||||||
|  |   import NodeList from "./systems/NodeList.svelte"; | ||||||
|  |  | ||||||
|  |   export let displayType; | ||||||
|   export let cluster; |   export let cluster; | ||||||
|   export let from = null; |   export let from = null; | ||||||
|   export let to = null; |   export let to = null; | ||||||
|  |  | ||||||
|   const { query: initq } = init(); |   console.assert( | ||||||
|  |     displayType == "OVERVIEW" || displayType == "LIST", | ||||||
|   if (from == null || to == null) { |     "Invalid nodes displayType provided!", | ||||||
|     to = new Date(Date.now()); |   ); | ||||||
|     from = new Date(to.getTime()); |  | ||||||
|     from.setHours(from.getHours() - 12); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const initialized = getContext("initialized"); |  | ||||||
|   const ccconfig = getContext("cc-config"); |  | ||||||
|   const clusters = getContext("clusters"); |  | ||||||
|   const globalMetrics = getContext("globalMetrics"); |  | ||||||
|  |  | ||||||
|   let hostnameFilter = ""; |  | ||||||
|   let selectedMetric = ccconfig.system_view_selectedMetric; |  | ||||||
|  |  | ||||||
|   const client = getContextClient(); |  | ||||||
|   $: nodesQuery = queryStore({ |  | ||||||
|     client: client, |  | ||||||
|     query: gql` |  | ||||||
|       query ($cluster: String!, $metrics: [String!], $from: Time!, $to: Time!) { |  | ||||||
|         nodeMetrics( |  | ||||||
|           cluster: $cluster |  | ||||||
|           metrics: $metrics |  | ||||||
|           from: $from |  | ||||||
|           to: $to |  | ||||||
|         ) { |  | ||||||
|           host |  | ||||||
|           subCluster |  | ||||||
|           metrics { |  | ||||||
|             name |  | ||||||
|             scope |  | ||||||
|             metric { |  | ||||||
|               timestep |  | ||||||
|               unit { |  | ||||||
|                 base |  | ||||||
|                 prefix |  | ||||||
|               } |  | ||||||
|               series { |  | ||||||
|                 statistics { |  | ||||||
|                   min |  | ||||||
|                   avg |  | ||||||
|                   max |  | ||||||
|                 } |  | ||||||
|                 data |  | ||||||
|               } |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     `, |  | ||||||
|     variables: { |  | ||||||
|       cluster: cluster, |  | ||||||
|       metrics: [selectedMetric], |  | ||||||
|       from: from.toISOString(), |  | ||||||
|       to: to.toISOString(), |  | ||||||
|     }, |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   let systemMetrics = []; |  | ||||||
|   let systemUnits = {}; |  | ||||||
|   function loadMetrics(isInitialized) { |  | ||||||
|     if (!isInitialized) return |  | ||||||
|     systemMetrics = [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] |  | ||||||
|     for (let sm of systemMetrics) { |  | ||||||
|       systemUnits[sm.name] = (sm?.unit?.prefix ? sm.unit.prefix : "") + (sm?.unit?.base ? sm.unit.base : "") |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   $: loadMetrics($initialized) |  | ||||||
|  |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <Row cols={{ xs: 2, lg: 4 }}> | {#if displayType === 'OVERVIEW'} | ||||||
|   {#if $initq.error} |   <NodeOverview {cluster} {from} {to}/> | ||||||
|     <Card body color="danger">{$initq.error.message}</Card> | {:else if displayType === 'LIST'} | ||||||
|   {:else if $initq.fetching} |   <NodeList {cluster} {from} {to}/> | ||||||
|     <Spinner /> |  | ||||||
|   {:else} |  | ||||||
|     <!-- Node Col--> |  | ||||||
|     <Col> |  | ||||||
|       <InputGroup> |  | ||||||
|         <InputGroupText><Icon name="hdd" /></InputGroupText> |  | ||||||
|         <InputGroupText>Find Node</InputGroupText> |  | ||||||
|         <Input |  | ||||||
|           placeholder="hostname..." |  | ||||||
|           type="text" |  | ||||||
|           bind:value={hostnameFilter} |  | ||||||
|         /> |  | ||||||
|       </InputGroup> |  | ||||||
|     </Col> |  | ||||||
|     <!-- Range Col--> |  | ||||||
|     <Col> |  | ||||||
|       <TimeSelection bind:from bind:to /> |  | ||||||
|     </Col> |  | ||||||
|     <!-- Metric Col--> |  | ||||||
|     <Col class="mt-2 mt-lg-0"> |  | ||||||
|       <InputGroup> |  | ||||||
|         <InputGroupText><Icon name="graph-up" /></InputGroupText> |  | ||||||
|         <InputGroupText>Metric</InputGroupText> |  | ||||||
|         <select class="form-select" bind:value={selectedMetric}> |  | ||||||
|           {#each systemMetrics as metric} |  | ||||||
|             <option value={metric.name} |  | ||||||
|               >{metric.name} {systemUnits[metric.name] ? "("+systemUnits[metric.name]+")" : ""}</option |  | ||||||
|             > |  | ||||||
|           {/each} |  | ||||||
|         </select> |  | ||||||
|       </InputGroup> |  | ||||||
|     </Col> |  | ||||||
|     <!-- Refresh Col--> |  | ||||||
|     <Col class="mt-2 mt-lg-0"> |  | ||||||
|       <Refresher |  | ||||||
|         on:refresh={() => { |  | ||||||
|           const diff = Date.now() - to; |  | ||||||
|           from = new Date(from.getTime() + diff); |  | ||||||
|           to = new Date(to.getTime() + diff); |  | ||||||
|         }} |  | ||||||
|       /> |  | ||||||
|     </Col> |  | ||||||
|   {/if} |  | ||||||
| </Row> |  | ||||||
| <br /> |  | ||||||
| {#if $nodesQuery.error} |  | ||||||
|   <Row> |  | ||||||
|     <Col> |  | ||||||
|       <Card body color="danger">{$nodesQuery.error.message}</Card> |  | ||||||
|     </Col> |  | ||||||
|   </Row> |  | ||||||
| {:else if $nodesQuery.fetching || $initq.fetching} |  | ||||||
|   <Row> |  | ||||||
|     <Col> |  | ||||||
|       <Spinner /> |  | ||||||
|     </Col> |  | ||||||
|   </Row> |  | ||||||
| {:else} | {:else} | ||||||
|   <PlotGrid | <Row> | ||||||
|     let:item |   <Col> | ||||||
|     renderFor="systems" |     <Card color="danger"> | ||||||
|     itemsPerRow={ccconfig.plot_view_plotsPerRow} |       Unknown displayList type! | ||||||
|     items={$nodesQuery.data.nodeMetrics |     </Card> | ||||||
|       .filter( |   </Col> | ||||||
|         (h) => | </Row> | ||||||
|           h.host.includes(hostnameFilter) && |  | ||||||
|           h.metrics.some( |  | ||||||
|             (m) => m.name == selectedMetric && m.scope == "node", |  | ||||||
|           ), |  | ||||||
|       ) |  | ||||||
|       .map((h) => ({ |  | ||||||
|         host: h.host, |  | ||||||
|         subCluster: h.subCluster, |  | ||||||
|         data: h.metrics.find( |  | ||||||
|           (m) => m.name == selectedMetric && m.scope == "node", |  | ||||||
|         ), |  | ||||||
|         disabled: checkMetricDisabled( |  | ||||||
|           selectedMetric, |  | ||||||
|           cluster, |  | ||||||
|           h.subCluster, |  | ||||||
|         ), |  | ||||||
|       })) |  | ||||||
|       .sort((a, b) => a.host.localeCompare(b.host))} |  | ||||||
|   > |  | ||||||
|     <h4 style="width: 100%; text-align: center;"> |  | ||||||
|       <a |  | ||||||
|         style="display: block;padding-top: 15px;" |  | ||||||
|         href="/monitoring/node/{cluster}/{item.host}" |  | ||||||
|         >{item.host} ({item.subCluster})</a |  | ||||||
|       > |  | ||||||
|     </h4> |  | ||||||
|     {#if item.disabled === false && item.data} |  | ||||||
|       <MetricPlot |  | ||||||
|         timestep={item.data.metric.timestep} |  | ||||||
|         series={item.data.metric.series} |  | ||||||
|         metric={item.data.name} |  | ||||||
|         cluster={clusters.find((c) => c.name == cluster)} |  | ||||||
|         subCluster={item.subCluster} |  | ||||||
|         forNode={true} |  | ||||||
|       /> |  | ||||||
|     {:else if item.disabled === true && item.data} |  | ||||||
|       <Card style="margin-left: 2rem;margin-right: 2rem;" body color="info" |  | ||||||
|         >Metric disabled for subcluster <code |  | ||||||
|           >{selectedMetric}:{item.subCluster}</code |  | ||||||
|         ></Card |  | ||||||
|       > |  | ||||||
|     {:else} |  | ||||||
|       <Card |  | ||||||
|         style="margin-left: 2rem;margin-right: 2rem;" |  | ||||||
|         body |  | ||||||
|         color="warning" |  | ||||||
|         >No dataset returned for <code>{selectedMetric}</code></Card |  | ||||||
|       > |  | ||||||
|     {/if} |  | ||||||
|   </PlotGrid> |  | ||||||
| {/if} | {/if} | ||||||
|   | |||||||
| @@ -24,39 +24,69 @@ | |||||||
|  |  | ||||||
| {#each links as item} | {#each links as item} | ||||||
|   {#if item.listOptions} |   {#if item.listOptions} | ||||||
|     <Dropdown nav inNavbar {direction}> |     {#if item.title === 'Nodes'} | ||||||
|       <DropdownToggle nav caret> |       <Dropdown nav inNavbar {direction}> | ||||||
|         <Icon name={item.icon} /> |         <DropdownToggle nav caret> | ||||||
|         {item.title} |           <Icon name={item.icon} /> | ||||||
|       </DropdownToggle> |           {item.title} | ||||||
|       <DropdownMenu class="dropdown-menu-lg-end"> |         </DropdownToggle> | ||||||
|         <DropdownItem |         <DropdownMenu class="dropdown-menu-lg-end"> | ||||||
|           href={item.href} |           {#each clusters as cluster} | ||||||
|         > |             <Dropdown nav direction="right"> | ||||||
|           All Clusters |               <DropdownToggle nav caret class="dropdown-item py-1 px-2"> | ||||||
|         </DropdownItem> |                 {cluster.name} | ||||||
|         <DropdownItem divider /> |               </DropdownToggle> | ||||||
|         {#each clusters as cluster} |               <DropdownMenu> | ||||||
|           <Dropdown nav direction="right"> |                 <DropdownItem class="py-1 px-2" | ||||||
|             <DropdownToggle nav caret class="dropdown-item py-1 px-2"> |                   href={item.href + cluster.name} | ||||||
|               {cluster.name} |                 > | ||||||
|             </DropdownToggle> |                   Node Overview | ||||||
|             <DropdownMenu> |                 </DropdownItem> | ||||||
|               <DropdownItem class="py-1 px-2" |                 <DropdownItem class="py-1 px-2" | ||||||
|                 href={item.href + '?cluster=' + cluster.name} |                   href={item.href + 'list/' + cluster.name} | ||||||
|               > |                 > | ||||||
|                 All Jobs |                   Node List | ||||||
|               </DropdownItem> |                 </DropdownItem> | ||||||
|               <DropdownItem class="py-1 px-2" |               </DropdownMenu> | ||||||
|                 href={item.href + '?cluster=' + cluster.name + '&state=running'} |             </Dropdown> | ||||||
|               > |           {/each} | ||||||
|                 Running Jobs |         </DropdownMenu> | ||||||
|               </DropdownItem> |       </Dropdown> | ||||||
|             </DropdownMenu> |     {:else} | ||||||
|           </Dropdown> |       <Dropdown nav inNavbar {direction}> | ||||||
|         {/each} |         <DropdownToggle nav caret> | ||||||
|       </DropdownMenu> |           <Icon name={item.icon} /> | ||||||
|     </Dropdown> |           {item.title} | ||||||
|  |         </DropdownToggle> | ||||||
|  |         <DropdownMenu class="dropdown-menu-lg-end"> | ||||||
|  |           <DropdownItem | ||||||
|  |             href={item.href} | ||||||
|  |           > | ||||||
|  |             All Clusters | ||||||
|  |           </DropdownItem> | ||||||
|  |           <DropdownItem divider /> | ||||||
|  |           {#each clusters as cluster} | ||||||
|  |             <Dropdown nav direction="right"> | ||||||
|  |               <DropdownToggle nav caret class="dropdown-item py-1 px-2"> | ||||||
|  |                 {cluster.name} | ||||||
|  |               </DropdownToggle> | ||||||
|  |               <DropdownMenu> | ||||||
|  |                 <DropdownItem class="py-1 px-2" | ||||||
|  |                   href={item.href + '?cluster=' + cluster.name} | ||||||
|  |                 > | ||||||
|  |                   All Jobs | ||||||
|  |                 </DropdownItem> | ||||||
|  |                 <DropdownItem class="py-1 px-2" | ||||||
|  |                   href={item.href + '?cluster=' + cluster.name + '&state=running'} | ||||||
|  |                 > | ||||||
|  |                   Running Jobs | ||||||
|  |                 </DropdownItem> | ||||||
|  |               </DropdownMenu> | ||||||
|  |             </Dropdown> | ||||||
|  |           {/each} | ||||||
|  |         </DropdownMenu> | ||||||
|  |       </Dropdown> | ||||||
|  |     {/if} | ||||||
|   {:else if !item.perCluster} |   {:else if !item.perCluster} | ||||||
|     <NavLink href={item.href} active={window.location.pathname == item.href} |     <NavLink href={item.href} active={window.location.pathname == item.href} | ||||||
|       ><Icon name={item.icon} /> {item.title}</NavLink |       ><Icon name={item.icon} /> {item.title}</NavLink | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import Systems from './Systems.root.svelte' | |||||||
| new Systems({ | new Systems({ | ||||||
|     target: document.getElementById('svelte-app'), |     target: document.getElementById('svelte-app'), | ||||||
|     props: { |     props: { | ||||||
|  |         displayType: displayType, | ||||||
|         cluster: infos.cluster, |         cluster: infos.cluster, | ||||||
|         from: infos.from, |         from: infos.from, | ||||||
|         to: infos.to |         to: infos.to | ||||||
|   | |||||||
							
								
								
									
										322
									
								
								web/frontend/src/systems/NodeList.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								web/frontend/src/systems/NodeList.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,322 @@ | |||||||
|  | <!-- | ||||||
|  |     @component Main jobList component; lists jobs according to set filters | ||||||
|  |  | ||||||
|  |     Properties: | ||||||
|  |     - `sorting Object?`: Currently active sorting [Default: {field: "startTime", type: "col", order: "DESC"}] | ||||||
|  |     - `matchedJobs Number?`: Number of matched jobs for selected filters [Default: 0] | ||||||
|  |     - `metrics [String]?`: The currently selected metrics [Default: User-Configured Selection] | ||||||
|  |     - `showFootprint Bool`: If to display the jobFootprint component | ||||||
|  |  | ||||||
|  |     Functions: | ||||||
|  |     - `refreshJobs()`: Load jobs data with unchanged parameters and 'network-only' keyword | ||||||
|  |     - `refreshAllMetrics()`: Trigger downstream refresh of all running jobs' metric data | ||||||
|  |     - `queryJobs(filters?: [JobFilter])`: Load jobs data with new filters, starts from page 1 | ||||||
|  |  --> | ||||||
|  |  | ||||||
|  | <script> | ||||||
|  |   import { getContext } from "svelte"; | ||||||
|  |   import { | ||||||
|  |     queryStore, | ||||||
|  |     gql, | ||||||
|  |     getContextClient, | ||||||
|  |     mutationStore, | ||||||
|  |   } from "@urql/svelte"; | ||||||
|  |   import { Row, Table, Card, Spinner } from "@sveltestrap/sveltestrap"; | ||||||
|  |   import { stickyHeader } from "../generic/utils.js"; | ||||||
|  |   import Pagination from "../generic/joblist/Pagination.svelte"; | ||||||
|  |   import JobListRow from "../generic/joblist/JobListRow.svelte"; | ||||||
|  |  | ||||||
|  |   const ccconfig = getContext("cc-config"), | ||||||
|  |     initialized = getContext("initialized"), | ||||||
|  |     globalMetrics = getContext("globalMetrics"); | ||||||
|  |  | ||||||
|  |   export let sorting = { field: "startTime", type: "col", order: "DESC" }; | ||||||
|  |   export let matchedJobs = 0; | ||||||
|  |   export let metrics = ccconfig.plot_list_selectedMetrics; | ||||||
|  |   export let showFootprint; | ||||||
|  |  | ||||||
|  |   let usePaging = ccconfig.job_list_usePaging | ||||||
|  |   let itemsPerPage = usePaging ? ccconfig.plot_list_jobsPerPage : 10; | ||||||
|  |   let page = 1; | ||||||
|  |   let paging = { itemsPerPage, page }; | ||||||
|  |   let filter = []; | ||||||
|  |   let triggerMetricRefresh = false; | ||||||
|  |  | ||||||
|  |   function getUnit(m) { | ||||||
|  |     const rawUnit = globalMetrics.find((gm) => gm.name === m)?.unit | ||||||
|  |     return (rawUnit?.prefix ? rawUnit.prefix : "") + (rawUnit?.base ? rawUnit.base : "") | ||||||
|  |   }  | ||||||
|  |  | ||||||
|  |   const client = getContextClient(); | ||||||
|  |   const query = gql` | ||||||
|  |     query ( | ||||||
|  |       $filter: [JobFilter!]! | ||||||
|  |       $sorting: OrderByInput! | ||||||
|  |       $paging: PageRequest! | ||||||
|  |     ) { | ||||||
|  |       jobs(filter: $filter, order: $sorting, page: $paging) { | ||||||
|  |         items { | ||||||
|  |           id | ||||||
|  |           jobId | ||||||
|  |           user | ||||||
|  |           project | ||||||
|  |           cluster | ||||||
|  |           subCluster | ||||||
|  |           startTime | ||||||
|  |           duration | ||||||
|  |           numNodes | ||||||
|  |           numHWThreads | ||||||
|  |           numAcc | ||||||
|  |           walltime | ||||||
|  |           resources { | ||||||
|  |             hostname | ||||||
|  |           } | ||||||
|  |           SMT | ||||||
|  |           exclusive | ||||||
|  |           partition | ||||||
|  |           arrayJobId | ||||||
|  |           monitoringStatus | ||||||
|  |           state | ||||||
|  |           tags { | ||||||
|  |             id | ||||||
|  |             type | ||||||
|  |             name | ||||||
|  |             scope | ||||||
|  |           } | ||||||
|  |           userData { | ||||||
|  |             name | ||||||
|  |           } | ||||||
|  |           metaData | ||||||
|  |           footprint { | ||||||
|  |             name | ||||||
|  |             stat | ||||||
|  |             value | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         count | ||||||
|  |         hasNextPage | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   `; | ||||||
|  |  | ||||||
|  |   $: jobsStore = queryStore({ | ||||||
|  |     client: client, | ||||||
|  |     query: query, | ||||||
|  |     variables: { paging, sorting, filter }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   let jobs = [] | ||||||
|  |   $: if ($initialized && $jobsStore.data) { | ||||||
|  |     jobs = [...$jobsStore.data.jobs.items] | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   $: matchedJobs = $jobsStore.data != null ? $jobsStore.data.jobs.count : -1; | ||||||
|  |  | ||||||
|  |   // Force refresh list with existing unchanged variables (== usually would not trigger reactivity) | ||||||
|  |   export function refreshJobs() { | ||||||
|  |     jobsStore = queryStore({ | ||||||
|  |       client: client, | ||||||
|  |       query: query, | ||||||
|  |       variables: { paging, sorting, filter }, | ||||||
|  |       requestPolicy: "network-only", | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   export function refreshAllMetrics() { | ||||||
|  |     // Refresh Job Metrics (Downstream will only query for running jobs) | ||||||
|  |     triggerMetricRefresh = true | ||||||
|  |     setTimeout(function () { | ||||||
|  |       triggerMetricRefresh = false; | ||||||
|  |     }, 100); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // (Re-)query and optionally set new filters; Query will be started reactively. | ||||||
|  |   export function queryJobs(filters) { | ||||||
|  |     if (filters != null) { | ||||||
|  |       let minRunningFor = ccconfig.plot_list_hideShortRunningJobs; | ||||||
|  |       if (minRunningFor && minRunningFor > 0) { | ||||||
|  |         filters.push({ minRunningFor }); | ||||||
|  |       } | ||||||
|  |       filter = filters; | ||||||
|  |     } | ||||||
|  |     page = 1; | ||||||
|  |     paging = paging = { page, itemsPerPage }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const updateConfigurationMutation = ({ name, value }) => { | ||||||
|  |     return mutationStore({ | ||||||
|  |       client: client, | ||||||
|  |       query: gql` | ||||||
|  |         mutation ($name: String!, $value: String!) { | ||||||
|  |           updateConfiguration(name: $name, value: $value) | ||||||
|  |         } | ||||||
|  |       `, | ||||||
|  |       variables: { name, value }, | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   function updateConfiguration(value, page) { | ||||||
|  |     updateConfigurationMutation({ | ||||||
|  |       name: "plot_list_jobsPerPage", | ||||||
|  |       value: value, | ||||||
|  |     }).subscribe((res) => { | ||||||
|  |       if (res.fetching === false && !res.error) { | ||||||
|  |         jobs = [] // Empty List | ||||||
|  |         paging = { itemsPerPage: value, page: page }; // Trigger reload of jobList | ||||||
|  |       } else if (res.fetching === false && res.error) { | ||||||
|  |         throw res.error; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!usePaging) { | ||||||
|  |     let scrollMultiplier = 1 | ||||||
|  |     window.addEventListener('scroll', () => { | ||||||
|  |       let { | ||||||
|  |         scrollTop, | ||||||
|  |         scrollHeight, | ||||||
|  |         clientHeight | ||||||
|  |       } = document.documentElement; | ||||||
|  |  | ||||||
|  |       // Add 100 px offset to trigger load earlier | ||||||
|  |       if (scrollTop + clientHeight >= scrollHeight - 100 && $jobsStore.data != null && $jobsStore.data.jobs.hasNextPage) { | ||||||
|  |         let pendingPaging = { ...paging } | ||||||
|  |         scrollMultiplier += 1 | ||||||
|  |         pendingPaging.itemsPerPage = itemsPerPage * scrollMultiplier | ||||||
|  |         paging = pendingPaging | ||||||
|  |       }; | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   let plotWidth = null; | ||||||
|  |   let tableWidth = null; | ||||||
|  |   let jobInfoColumnWidth = 250; | ||||||
|  |  | ||||||
|  |   $: if (showFootprint) { | ||||||
|  |     plotWidth = Math.floor( | ||||||
|  |       (tableWidth - jobInfoColumnWidth) / (metrics.length + 1) - 10, | ||||||
|  |     ); | ||||||
|  |   } else { | ||||||
|  |     plotWidth = Math.floor( | ||||||
|  |       (tableWidth - jobInfoColumnWidth) / metrics.length - 10, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   let headerPaddingTop = 0; | ||||||
|  |   stickyHeader( | ||||||
|  |     ".cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)", | ||||||
|  |     (x) => (headerPaddingTop = x), | ||||||
|  |   ); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <Row> | ||||||
|  |   <div class="col cc-table-wrapper" bind:clientWidth={tableWidth}> | ||||||
|  |     <Table cellspacing="0px" cellpadding="0px"> | ||||||
|  |       <thead> | ||||||
|  |         <tr> | ||||||
|  |           <th | ||||||
|  |             class="position-sticky top-0" | ||||||
|  |             scope="col" | ||||||
|  |             style="width: {jobInfoColumnWidth}px; padding-top: {headerPaddingTop}px" | ||||||
|  |           > | ||||||
|  |             Job Info | ||||||
|  |           </th> | ||||||
|  |           {#if showFootprint} | ||||||
|  |             <th | ||||||
|  |               class="position-sticky top-0" | ||||||
|  |               scope="col" | ||||||
|  |               style="width: {plotWidth}px; padding-top: {headerPaddingTop}px" | ||||||
|  |             > | ||||||
|  |               Job Footprint | ||||||
|  |             </th> | ||||||
|  |           {/if} | ||||||
|  |           {#each metrics as metric (metric)} | ||||||
|  |             <th | ||||||
|  |               class="position-sticky top-0 text-center" | ||||||
|  |               scope="col" | ||||||
|  |               style="width: {plotWidth}px; padding-top: {headerPaddingTop}px" | ||||||
|  |             > | ||||||
|  |               {metric} | ||||||
|  |               {#if $initialized} | ||||||
|  |                 ({getUnit(metric)}) | ||||||
|  |               {/if} | ||||||
|  |             </th> | ||||||
|  |           {/each} | ||||||
|  |         </tr> | ||||||
|  |       </thead> | ||||||
|  |       <tbody> | ||||||
|  |         {#if $jobsStore.error} | ||||||
|  |           <tr> | ||||||
|  |             <td colspan={metrics.length + 1}> | ||||||
|  |               <Card body color="danger" class="mb-3" | ||||||
|  |                 ><h2>{$jobsStore.error.message}</h2></Card | ||||||
|  |               > | ||||||
|  |             </td> | ||||||
|  |           </tr> | ||||||
|  |         {:else} | ||||||
|  |           {#each jobs as job (job)} | ||||||
|  |             <JobListRow bind:triggerMetricRefresh {job} {metrics} {plotWidth} {showFootprint} /> | ||||||
|  |           {:else} | ||||||
|  |             <tr> | ||||||
|  |               <td colspan={metrics.length + 1}> No jobs found </td> | ||||||
|  |             </tr> | ||||||
|  |           {/each} | ||||||
|  |         {/if} | ||||||
|  |         {#if $jobsStore.fetching || !$jobsStore.data} | ||||||
|  |           <tr> | ||||||
|  |             <td colspan={metrics.length + 1}> | ||||||
|  |               <div style="text-align:center;"> | ||||||
|  |                 <Spinner secondary /> | ||||||
|  |               </div> | ||||||
|  |             </td> | ||||||
|  |           </tr> | ||||||
|  |         {/if} | ||||||
|  |       </tbody> | ||||||
|  |     </Table> | ||||||
|  |   </div> | ||||||
|  | </Row> | ||||||
|  |  | ||||||
|  | {#if usePaging} | ||||||
|  |   <Pagination | ||||||
|  |     bind:page | ||||||
|  |     {itemsPerPage} | ||||||
|  |     itemText="Jobs" | ||||||
|  |     totalItems={matchedJobs} | ||||||
|  |     on:update-paging={({ detail }) => { | ||||||
|  |       if (detail.itemsPerPage != itemsPerPage) { | ||||||
|  |         updateConfiguration(detail.itemsPerPage.toString(), detail.page); | ||||||
|  |       } else { | ||||||
|  |         jobs = [] | ||||||
|  |         paging = { itemsPerPage: detail.itemsPerPage, page: detail.page }; | ||||||
|  |       } | ||||||
|  |     }} | ||||||
|  |   /> | ||||||
|  | {/if} | ||||||
|  |  | ||||||
|  | <style> | ||||||
|  |   .cc-table-wrapper { | ||||||
|  |     overflow: initial; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .cc-table-wrapper > :global(table) { | ||||||
|  |     border-collapse: separate; | ||||||
|  |     border-spacing: 0px; | ||||||
|  |     table-layout: fixed; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .cc-table-wrapper :global(button) { | ||||||
|  |     margin-bottom: 0px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .cc-table-wrapper > :global(table > tbody > tr > td) { | ||||||
|  |     margin: 0px; | ||||||
|  |     padding-left: 5px; | ||||||
|  |     padding-right: 0px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   th.position-sticky.top-0 { | ||||||
|  |     background-color: white; | ||||||
|  |     z-index: 10; | ||||||
|  |     border-bottom: 1px solid black; | ||||||
|  |   } | ||||||
|  | </style> | ||||||
							
								
								
									
										232
									
								
								web/frontend/src/systems/NodeOverview.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								web/frontend/src/systems/NodeOverview.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,232 @@ | |||||||
|  | <!-- | ||||||
|  |     @component Cluster Per Node Overview component; renders current state of ONE metric for ALL nodes | ||||||
|  |  | ||||||
|  |     Properties: | ||||||
|  |     - `cluster String`: The cluster to show status information for | ||||||
|  |     - `from Date?`: Custom Time Range selection 'from' [Default: null] | ||||||
|  |     - `to Date?`: Custom Time Range selection 'to' [Default: null] | ||||||
|  |  --> | ||||||
|  |  | ||||||
|  |  <script> | ||||||
|  |   import { getContext } from "svelte"; | ||||||
|  |   import { | ||||||
|  |     Row, | ||||||
|  |     Col, | ||||||
|  |     Input, | ||||||
|  |     InputGroup, | ||||||
|  |     InputGroupText, | ||||||
|  |     Icon, | ||||||
|  |     Spinner, | ||||||
|  |     Card, | ||||||
|  |   } from "@sveltestrap/sveltestrap"; | ||||||
|  |   import { | ||||||
|  |     queryStore, | ||||||
|  |     gql, | ||||||
|  |     getContextClient, | ||||||
|  |   } from "@urql/svelte"; | ||||||
|  |   import { | ||||||
|  |     init, | ||||||
|  |     checkMetricDisabled, | ||||||
|  |   } from "../generic/utils.js"; | ||||||
|  |   import PlotGrid from "../generic/PlotGrid.svelte"; | ||||||
|  |   import MetricPlot from "../generic/plots/MetricPlot.svelte"; | ||||||
|  |   import TimeSelection from "../generic/select/TimeSelection.svelte"; | ||||||
|  |   import Refresher from "../generic/helper/Refresher.svelte"; | ||||||
|  |  | ||||||
|  |   export let cluster; | ||||||
|  |   export let from = null; | ||||||
|  |   export let to = null; | ||||||
|  |  | ||||||
|  |   const { query: initq } = init(); | ||||||
|  |  | ||||||
|  |   if (from == null || to == null) { | ||||||
|  |     to = new Date(Date.now()); | ||||||
|  |     from = new Date(to.getTime()); | ||||||
|  |     from.setHours(from.getHours() - 12); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const initialized = getContext("initialized"); | ||||||
|  |   const ccconfig = getContext("cc-config"); | ||||||
|  |   const clusters = getContext("clusters"); | ||||||
|  |   const globalMetrics = getContext("globalMetrics"); | ||||||
|  |  | ||||||
|  |   let hostnameFilter = ""; | ||||||
|  |   let selectedMetric = ccconfig.system_view_selectedMetric; | ||||||
|  |  | ||||||
|  |   const client = getContextClient(); | ||||||
|  |   $: nodesQuery = queryStore({ | ||||||
|  |     client: client, | ||||||
|  |     query: gql` | ||||||
|  |       query ($cluster: String!, $metrics: [String!], $from: Time!, $to: Time!) { | ||||||
|  |         nodeMetrics( | ||||||
|  |           cluster: $cluster | ||||||
|  |           metrics: $metrics | ||||||
|  |           from: $from | ||||||
|  |           to: $to | ||||||
|  |         ) { | ||||||
|  |           host | ||||||
|  |           subCluster | ||||||
|  |           metrics { | ||||||
|  |             name | ||||||
|  |             scope | ||||||
|  |             metric { | ||||||
|  |               timestep | ||||||
|  |               unit { | ||||||
|  |                 base | ||||||
|  |                 prefix | ||||||
|  |               } | ||||||
|  |               series { | ||||||
|  |                 statistics { | ||||||
|  |                   min | ||||||
|  |                   avg | ||||||
|  |                   max | ||||||
|  |                 } | ||||||
|  |                 data | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     `, | ||||||
|  |     variables: { | ||||||
|  |       cluster: cluster, | ||||||
|  |       metrics: [selectedMetric], | ||||||
|  |       from: from.toISOString(), | ||||||
|  |       to: to.toISOString(), | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   let systemMetrics = []; | ||||||
|  |   let systemUnits = {}; | ||||||
|  |   function loadMetrics(isInitialized) { | ||||||
|  |     if (!isInitialized) return | ||||||
|  |     systemMetrics = [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] | ||||||
|  |     for (let sm of systemMetrics) { | ||||||
|  |       systemUnits[sm.name] = (sm?.unit?.prefix ? sm.unit.prefix : "") + (sm?.unit?.base ? sm.unit.base : "") | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   $: loadMetrics($initialized) | ||||||
|  |  | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <Row cols={{ xs: 2, lg: 4 }}> | ||||||
|  |   {#if $initq.error} | ||||||
|  |     <Card body color="danger">{$initq.error.message}</Card> | ||||||
|  |   {:else if $initq.fetching} | ||||||
|  |     <Spinner /> | ||||||
|  |   {:else} | ||||||
|  |     <!-- Node Col--> | ||||||
|  |     <Col> | ||||||
|  |       <InputGroup> | ||||||
|  |         <InputGroupText><Icon name="hdd" /></InputGroupText> | ||||||
|  |         <InputGroupText>Find Node</InputGroupText> | ||||||
|  |         <Input | ||||||
|  |           placeholder="hostname..." | ||||||
|  |           type="text" | ||||||
|  |           bind:value={hostnameFilter} | ||||||
|  |         /> | ||||||
|  |       </InputGroup> | ||||||
|  |     </Col> | ||||||
|  |     <!-- Range Col--> | ||||||
|  |     <Col> | ||||||
|  |       <TimeSelection bind:from bind:to /> | ||||||
|  |     </Col> | ||||||
|  |     <!-- Metric Col--> | ||||||
|  |     <Col class="mt-2 mt-lg-0"> | ||||||
|  |       <InputGroup> | ||||||
|  |         <InputGroupText><Icon name="graph-up" /></InputGroupText> | ||||||
|  |         <InputGroupText>Metric</InputGroupText> | ||||||
|  |         <select class="form-select" bind:value={selectedMetric}> | ||||||
|  |           {#each systemMetrics as metric} | ||||||
|  |             <option value={metric.name} | ||||||
|  |               >{metric.name} {systemUnits[metric.name] ? "("+systemUnits[metric.name]+")" : ""}</option | ||||||
|  |             > | ||||||
|  |           {/each} | ||||||
|  |         </select> | ||||||
|  |       </InputGroup> | ||||||
|  |     </Col> | ||||||
|  |     <!-- Refresh Col--> | ||||||
|  |     <Col class="mt-2 mt-lg-0"> | ||||||
|  |       <Refresher | ||||||
|  |         on:refresh={() => { | ||||||
|  |           const diff = Date.now() - to; | ||||||
|  |           from = new Date(from.getTime() + diff); | ||||||
|  |           to = new Date(to.getTime() + diff); | ||||||
|  |         }} | ||||||
|  |       /> | ||||||
|  |     </Col> | ||||||
|  |   {/if} | ||||||
|  | </Row> | ||||||
|  | <br /> | ||||||
|  | {#if $nodesQuery.error} | ||||||
|  |   <Row> | ||||||
|  |     <Col> | ||||||
|  |       <Card body color="danger">{$nodesQuery.error.message}</Card> | ||||||
|  |     </Col> | ||||||
|  |   </Row> | ||||||
|  | {:else if $nodesQuery.fetching || $initq.fetching} | ||||||
|  |   <Row> | ||||||
|  |     <Col> | ||||||
|  |       <Spinner /> | ||||||
|  |     </Col> | ||||||
|  |   </Row> | ||||||
|  | {:else} | ||||||
|  |   <PlotGrid | ||||||
|  |     let:item | ||||||
|  |     renderFor="systems" | ||||||
|  |     itemsPerRow={ccconfig.plot_view_plotsPerRow} | ||||||
|  |     items={$nodesQuery.data.nodeMetrics | ||||||
|  |       .filter( | ||||||
|  |         (h) => | ||||||
|  |           h.host.includes(hostnameFilter) && | ||||||
|  |           h.metrics.some( | ||||||
|  |             (m) => m.name == selectedMetric && m.scope == "node", | ||||||
|  |           ), | ||||||
|  |       ) | ||||||
|  |       .map((h) => ({ | ||||||
|  |         host: h.host, | ||||||
|  |         subCluster: h.subCluster, | ||||||
|  |         data: h.metrics.find( | ||||||
|  |           (m) => m.name == selectedMetric && m.scope == "node", | ||||||
|  |         ), | ||||||
|  |         disabled: checkMetricDisabled( | ||||||
|  |           selectedMetric, | ||||||
|  |           cluster, | ||||||
|  |           h.subCluster, | ||||||
|  |         ), | ||||||
|  |       })) | ||||||
|  |       .sort((a, b) => a.host.localeCompare(b.host))} | ||||||
|  |   > | ||||||
|  |     <h4 style="width: 100%; text-align: center;"> | ||||||
|  |       <a | ||||||
|  |         style="display: block;padding-top: 15px;" | ||||||
|  |         href="/monitoring/node/{cluster}/{item.host}" | ||||||
|  |         >{item.host} ({item.subCluster})</a | ||||||
|  |       > | ||||||
|  |     </h4> | ||||||
|  |     {#if item.disabled === false && item.data} | ||||||
|  |       <MetricPlot | ||||||
|  |         timestep={item.data.metric.timestep} | ||||||
|  |         series={item.data.metric.series} | ||||||
|  |         metric={item.data.name} | ||||||
|  |         cluster={clusters.find((c) => c.name == cluster)} | ||||||
|  |         subCluster={item.subCluster} | ||||||
|  |         forNode={true} | ||||||
|  |       /> | ||||||
|  |     {:else if item.disabled === true && item.data} | ||||||
|  |       <Card style="margin-left: 2rem;margin-right: 2rem;" body color="info" | ||||||
|  |         >Metric disabled for subcluster <code | ||||||
|  |           >{selectedMetric}:{item.subCluster}</code | ||||||
|  |         ></Card | ||||||
|  |       > | ||||||
|  |     {:else} | ||||||
|  |       <Card | ||||||
|  |         style="margin-left: 2rem;margin-right: 2rem;" | ||||||
|  |         body | ||||||
|  |         color="warning" | ||||||
|  |         >No dataset returned for <code>{selectedMetric}</code></Card | ||||||
|  |       > | ||||||
|  |     {/if} | ||||||
|  |   </PlotGrid> | ||||||
|  | {/if} | ||||||
							
								
								
									
										152
									
								
								web/frontend/src/systems/nodelist/NodeInfo.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								web/frontend/src/systems/nodelist/NodeInfo.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,152 @@ | |||||||
|  | <!-- | ||||||
|  |     @component Displays job metaData, serves links to detail pages | ||||||
|  |  | ||||||
|  |     Properties: | ||||||
|  |     - `job Object`: The Job Object (GraphQL.Job) | ||||||
|  |     - `jobTags [Number]?`: The jobs tags as IDs, default useful for dynamically updating the tags [Default: job.tags] | ||||||
|  |  --> | ||||||
|  |  | ||||||
|  | <script> | ||||||
|  |   import { Badge, Icon } from "@sveltestrap/sveltestrap"; | ||||||
|  |   import { scrambleNames, scramble } from "../../generic/utils.js"; | ||||||
|  |   import Tag from "../../generic/helper/Tag.svelte"; | ||||||
|  |   import TagManagement from "../../generic/helper/TagManagement.svelte"; | ||||||
|  |  | ||||||
|  |   export let job; | ||||||
|  |   export let jobTags = job.tags; | ||||||
|  |   export let showTagedit = false; | ||||||
|  |   export let username = null; | ||||||
|  |   export let authlevel= null; | ||||||
|  |   export let roles = null; | ||||||
|  |  | ||||||
|  |   function formatDuration(duration) { | ||||||
|  |     const hours = Math.floor(duration / 3600); | ||||||
|  |     duration -= hours * 3600; | ||||||
|  |     const minutes = Math.floor(duration / 60); | ||||||
|  |     duration -= minutes * 60; | ||||||
|  |     const seconds = duration; | ||||||
|  |     return `${hours}:${("0" + minutes).slice(-2)}:${("0" + seconds).slice(-2)}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function getStateColor(state) { | ||||||
|  |     switch (state) { | ||||||
|  |       case "running": | ||||||
|  |         return "success"; | ||||||
|  |       case "completed": | ||||||
|  |         return "primary"; | ||||||
|  |       default: | ||||||
|  |         return "danger"; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <div> | ||||||
|  |   <p class="mb-2"> | ||||||
|  |     <span class="fw-bold" | ||||||
|  |       ><a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a> | ||||||
|  |       ({job.cluster})</span | ||||||
|  |     > | ||||||
|  |     {#if job.metaData?.jobName} | ||||||
|  |       <br /> | ||||||
|  |       {#if job.metaData?.jobName.length <= 25} | ||||||
|  |         <div>{job.metaData.jobName}</div> | ||||||
|  |       {:else} | ||||||
|  |         <div | ||||||
|  |           class="truncate" | ||||||
|  |           style="cursor:help; width:230px;" | ||||||
|  |           title={job.metaData.jobName} | ||||||
|  |         > | ||||||
|  |           {job.metaData.jobName} | ||||||
|  |         </div> | ||||||
|  |       {/if} | ||||||
|  |     {/if} | ||||||
|  |     {#if job.arrayJobId} | ||||||
|  |       Array Job: <a | ||||||
|  |         href="/monitoring/jobs/?arrayJobId={job.arrayJobId}&cluster={job.cluster}" | ||||||
|  |         target="_blank">#{job.arrayJobId}</a | ||||||
|  |       > | ||||||
|  |     {/if} | ||||||
|  |   </p> | ||||||
|  |  | ||||||
|  |   <p class="mb-2"> | ||||||
|  |     <Icon name="person-fill" /> | ||||||
|  |     <a class="fst-italic" href="/monitoring/user/{job.user}" target="_blank"> | ||||||
|  |       {scrambleNames ? scramble(job.user) : job.user} | ||||||
|  |     </a> | ||||||
|  |     {#if job.userData && job.userData.name} | ||||||
|  |       ({scrambleNames ? scramble(job.userData.name) : job.userData.name}) | ||||||
|  |     {/if} | ||||||
|  |     {#if job.project && job.project != "no project"} | ||||||
|  |       <br /> | ||||||
|  |       <Icon name="people-fill" /> | ||||||
|  |       <a | ||||||
|  |         class="fst-italic" | ||||||
|  |         href="/monitoring/jobs/?project={job.project}&projectMatch=eq" | ||||||
|  |         target="_blank" | ||||||
|  |       > | ||||||
|  |         {scrambleNames ? scramble(job.project) : job.project} | ||||||
|  |       </a> | ||||||
|  |     {/if} | ||||||
|  |   </p> | ||||||
|  |  | ||||||
|  |   <p class="mb-2"> | ||||||
|  |     {#if job.numNodes == 1} | ||||||
|  |       {job.resources[0].hostname} | ||||||
|  |     {:else} | ||||||
|  |       {job.numNodes} | ||||||
|  |     {/if} | ||||||
|  |     <Icon name="pc-horizontal" /> | ||||||
|  |     {#if job.exclusive != 1} | ||||||
|  |       (shared) | ||||||
|  |     {/if} | ||||||
|  |     {#if job.numAcc > 0} | ||||||
|  |       , {job.numAcc} <Icon name="gpu-card" /> | ||||||
|  |     {/if} | ||||||
|  |     {#if job.numHWThreads > 0} | ||||||
|  |       , {job.numHWThreads} <Icon name="cpu" /> | ||||||
|  |     {/if} | ||||||
|  |     <br /> | ||||||
|  |     {job.subCluster} | ||||||
|  |   </p> | ||||||
|  |  | ||||||
|  |   <p class="mb-2"> | ||||||
|  |     Start: <span class="fw-bold" | ||||||
|  |       >{new Date(job.startTime).toLocaleString()}</span | ||||||
|  |     > | ||||||
|  |     <br /> | ||||||
|  |     Duration: <span class="fw-bold">{formatDuration(job.duration)}</span> | ||||||
|  |     <Badge color={getStateColor(job.state)}>{job.state}</Badge> | ||||||
|  |     {#if job.walltime} | ||||||
|  |       <br /> | ||||||
|  |       Walltime: <span class="fw-bold">{formatDuration(job.walltime)}</span> | ||||||
|  |     {/if} | ||||||
|  |   </p> | ||||||
|  |  | ||||||
|  |   {#if showTagedit} | ||||||
|  |     <hr class="mt-0 mb-2"/> | ||||||
|  |     <p class="mb-1"> | ||||||
|  |       <TagManagement bind:jobTags {job} {username} {authlevel} {roles} renderModal/> :  | ||||||
|  |       {#if jobTags?.length > 0} | ||||||
|  |         {#each jobTags as tag} | ||||||
|  |           <Tag {tag}/> | ||||||
|  |         {/each} | ||||||
|  |       {:else} | ||||||
|  |         <span style="font-size: 0.9rem; background-color: lightgray;" class="my-1 badge text-dark">No Tags</span> | ||||||
|  |       {/if} | ||||||
|  |     </p> | ||||||
|  |   {:else} | ||||||
|  |     <p class="mb-1"> | ||||||
|  |       {#each jobTags as tag} | ||||||
|  |         <Tag {tag} /> | ||||||
|  |       {/each} | ||||||
|  |     </p> | ||||||
|  |   {/if} | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <style> | ||||||
|  |   .truncate { | ||||||
|  |     overflow: hidden; | ||||||
|  |     text-overflow: ellipsis; | ||||||
|  |     white-space: nowrap; | ||||||
|  |   } | ||||||
|  | </style> | ||||||
							
								
								
									
										207
									
								
								web/frontend/src/systems/nodelist/NodeListRow.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								web/frontend/src/systems/nodelist/NodeListRow.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,207 @@ | |||||||
|  | <!--  | ||||||
|  |     @component Data row for a single job displaying metric plots | ||||||
|  |  | ||||||
|  |     Properties: | ||||||
|  |     - `job Object`: The job object (GraphQL.Job) | ||||||
|  |     - `metrics [String]`: Currently selected metrics | ||||||
|  |     - `plotWidth Number`: Width of the sub-components | ||||||
|  |     - `plotHeight Number?`: Height of the sub-components [Default: 275] | ||||||
|  |     - `showFootprint Bool`: Display of footprint component for job | ||||||
|  |     - `triggerMetricRefresh Bool?`: If changed to true from upstream, will trigger metric query | ||||||
|  |  --> | ||||||
|  |  | ||||||
|  | <script> | ||||||
|  |   import { queryStore, gql, getContextClient } from "@urql/svelte"; | ||||||
|  |   import { getContext } from "svelte"; | ||||||
|  |   import { Card, Spinner } from "@sveltestrap/sveltestrap"; | ||||||
|  |   import { maxScope, checkMetricDisabled } from "../../generic/utils.js"; | ||||||
|  |   import JobInfo from "./NodeInfo.svelte"; | ||||||
|  |   import MetricPlot from "../../generic/plots/MetricPlot.svelte"; | ||||||
|  |   import JobFootprint from "../../generic/helper/JobFootprint.svelte"; | ||||||
|  |  | ||||||
|  |   export let job; | ||||||
|  |   export let metrics; | ||||||
|  |   export let plotWidth; | ||||||
|  |   export let plotHeight = 275; | ||||||
|  |   export let showFootprint; | ||||||
|  |   export let triggerMetricRefresh = false; | ||||||
|  |  | ||||||
|  |   const resampleConfig = getContext("resampling") || null; | ||||||
|  |   const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0; | ||||||
|  |    | ||||||
|  |   let { id } = job; | ||||||
|  |   let scopes = job.numNodes == 1 | ||||||
|  |     ? job.numAcc >= 1 | ||||||
|  |       ? ["core", "accelerator"] | ||||||
|  |       : ["core"] | ||||||
|  |     : ["node"]; | ||||||
|  |   let selectedResolution = resampleDefault; | ||||||
|  |   let zoomStates = {}; | ||||||
|  |  | ||||||
|  |   const cluster = getContext("clusters").find((c) => c.name == job.cluster); | ||||||
|  |   const client = getContextClient(); | ||||||
|  |   const query = gql` | ||||||
|  |     query ($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!, $selectedResolution: Int) { | ||||||
|  |       jobMetrics(id: $id, metrics: $metrics, scopes: $scopes, resolution: $selectedResolution) { | ||||||
|  |         name | ||||||
|  |         scope | ||||||
|  |         metric { | ||||||
|  |           unit { | ||||||
|  |             prefix | ||||||
|  |             base | ||||||
|  |           } | ||||||
|  |           timestep | ||||||
|  |           statisticsSeries { | ||||||
|  |             min | ||||||
|  |             mean | ||||||
|  |             median | ||||||
|  |             max | ||||||
|  |           } | ||||||
|  |           series { | ||||||
|  |             hostname | ||||||
|  |             id | ||||||
|  |             data | ||||||
|  |             statistics { | ||||||
|  |               min | ||||||
|  |               avg | ||||||
|  |               max | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   `; | ||||||
|  |  | ||||||
|  |   function handleZoom(detail, metric) { | ||||||
|  |     if ( // States have to differ, causes deathloop if just set | ||||||
|  |         (zoomStates[metric]?.x?.min !== detail?.lastZoomState?.x?.min) && | ||||||
|  |         (zoomStates[metric]?.y?.max !== detail?.lastZoomState?.y?.max) | ||||||
|  |     ) { | ||||||
|  |         zoomStates[metric] = {...detail.lastZoomState} | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (detail?.newRes) { // Triggers GQL | ||||||
|  |         selectedResolution = detail.newRes | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   $: metricsQuery = queryStore({ | ||||||
|  |     client: client, | ||||||
|  |     query: query, | ||||||
|  |     variables: { id, metrics, scopes, selectedResolution }, | ||||||
|  |   }); | ||||||
|  |    | ||||||
|  |   function refreshMetrics() { | ||||||
|  |     metricsQuery = queryStore({ | ||||||
|  |       client: client, | ||||||
|  |       query: query, | ||||||
|  |       variables: { id, metrics, scopes, selectedResolution }, | ||||||
|  |       // requestPolicy: 'network-only' // use default cache-first for refresh | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   $: if (job.state === 'running' && triggerMetricRefresh === true) { | ||||||
|  |     refreshMetrics(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Helper | ||||||
|  |   const selectScope = (jobMetrics) => | ||||||
|  |     jobMetrics.reduce( | ||||||
|  |       (a, b) => | ||||||
|  |         maxScope([a.scope, b.scope]) == a.scope | ||||||
|  |           ? job.numNodes > 1 | ||||||
|  |             ? a | ||||||
|  |             : b | ||||||
|  |           : job.numNodes > 1 | ||||||
|  |             ? b | ||||||
|  |             : a, | ||||||
|  |       jobMetrics[0], | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |   const sortAndSelectScope = (jobMetrics) => | ||||||
|  |     metrics | ||||||
|  |       .map((name) => jobMetrics.filter((jobMetric) => jobMetric.name == name)) | ||||||
|  |       .map((jobMetrics) => ({ | ||||||
|  |         disabled: false, | ||||||
|  |         data: jobMetrics.length > 0 ? selectScope(jobMetrics) : null, | ||||||
|  |       })) | ||||||
|  |       .map((jobMetric) => { | ||||||
|  |         if (jobMetric.data) { | ||||||
|  |           return { | ||||||
|  |             disabled: checkMetricDisabled( | ||||||
|  |               jobMetric.data.name, | ||||||
|  |               job.cluster, | ||||||
|  |               job.subCluster, | ||||||
|  |             ), | ||||||
|  |             data: jobMetric.data, | ||||||
|  |           }; | ||||||
|  |         } else { | ||||||
|  |           return jobMetric; | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <tr> | ||||||
|  |   <td> | ||||||
|  |     <JobInfo {job} /> | ||||||
|  |   </td> | ||||||
|  |   {#if job.monitoringStatus == 0 || job.monitoringStatus == 2} | ||||||
|  |     <td colspan={metrics.length}> | ||||||
|  |       <Card body color="warning">Not monitored or archiving failed</Card> | ||||||
|  |     </td> | ||||||
|  |   {:else if $metricsQuery.fetching} | ||||||
|  |     <td colspan={metrics.length} style="text-align: center;"> | ||||||
|  |       <Spinner secondary /> | ||||||
|  |     </td> | ||||||
|  |   {:else if $metricsQuery.error} | ||||||
|  |     <td colspan={metrics.length}> | ||||||
|  |       <Card body color="danger" class="mb-3"> | ||||||
|  |         {$metricsQuery.error.message.length > 500 | ||||||
|  |           ? $metricsQuery.error.message.substring(0, 499) + "..." | ||||||
|  |           : $metricsQuery.error.message} | ||||||
|  |       </Card> | ||||||
|  |     </td> | ||||||
|  |   {:else} | ||||||
|  |     {#if showFootprint} | ||||||
|  |       <td> | ||||||
|  |         <JobFootprint | ||||||
|  |           {job} | ||||||
|  |           width={plotWidth} | ||||||
|  |           height="{plotHeight}px" | ||||||
|  |           displayTitle={false} | ||||||
|  |         /> | ||||||
|  |       </td> | ||||||
|  |     {/if} | ||||||
|  |     {#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric || i)} | ||||||
|  |       <td> | ||||||
|  |         <!-- Subluster Metricconfig remove keyword for jobtables (joblist main, user joblist, project joblist) to be used here as toplevel case--> | ||||||
|  |         {#if metric.disabled == false && metric.data} | ||||||
|  |           <MetricPlot | ||||||
|  |             on:zoom={({detail}) => { handleZoom(detail, metric.data.name) }} | ||||||
|  |             height={plotHeight} | ||||||
|  |             timestep={metric.data.metric.timestep} | ||||||
|  |             scope={metric.data.scope} | ||||||
|  |             series={metric.data.metric.series} | ||||||
|  |             statisticsSeries={metric.data.metric.statisticsSeries} | ||||||
|  |             metric={metric.data.name} | ||||||
|  |             {cluster} | ||||||
|  |             subCluster={job.subCluster} | ||||||
|  |             isShared={job.exclusive != 1} | ||||||
|  |             numhwthreads={job.numHWThreads} | ||||||
|  |             numaccs={job.numAcc} | ||||||
|  |             zoomState={zoomStates[metric.data.name] || null} | ||||||
|  |           /> | ||||||
|  |         {:else if metric.disabled == true && metric.data} | ||||||
|  |           <Card body color="info" | ||||||
|  |             >Metric disabled for subcluster <code | ||||||
|  |               >{metric.data.name}:{job.subCluster}</code | ||||||
|  |             ></Card | ||||||
|  |           > | ||||||
|  |         {:else} | ||||||
|  |           <Card body color="warning">No dataset returned</Card> | ||||||
|  |         {/if} | ||||||
|  |       </td> | ||||||
|  |     {/each} | ||||||
|  |   {/if} | ||||||
|  | </tr> | ||||||
| @@ -7,6 +7,7 @@ | |||||||
| {{end}} | {{end}} | ||||||
| {{define "javascript"}} | {{define "javascript"}} | ||||||
|     <script> |     <script> | ||||||
|  |         const displayType = {{ .Infos.displayType }}; | ||||||
|         const infos = {{ .Infos }}; |         const infos = {{ .Infos }}; | ||||||
|         const clusterCockpitConfig = {{ .Config }}; |         const clusterCockpitConfig = {{ .Config }}; | ||||||
|     </script> |     </script> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user