mirror of
				https://github.com/ClusterCockpit/cc-backend
				synced 2025-10-31 16:05:06 +01:00 
			
		
		
		
	Merge branch 'dev' into metricstore
This commit is contained in:
		| @@ -459,7 +459,7 @@ | ||||
|             </tr> | ||||
|             {#each $topQuery.data.topList as te, i} | ||||
|               <tr> | ||||
|                 <td><Icon name="circle-fill" style="color: {colors[i]};" /></td> | ||||
|                 <td><Icon name="circle-fill" style="color: {colors['colorblind'][i]};" /></td> | ||||
|                 {#if groupSelection.key == "user"} | ||||
|                   <th scope="col" id="topName-{te.id}" | ||||
|                     ><a href="/monitoring/user/{te.id}?cluster={clusterName}" | ||||
|   | ||||
| @@ -2,709 +2,62 @@ | ||||
|   @component Main cluster status view component; renders current system-usage information | ||||
|  | ||||
|   Properties: | ||||
|   - `cluster String`: The cluster to show status information for | ||||
|   - `presetCluster String`: The cluster to show status information for | ||||
| --> | ||||
|  | ||||
|  <script> | ||||
|   import { getContext } from "svelte"; | ||||
|   import { | ||||
|     getContext | ||||
|   } from "svelte" | ||||
|   import { | ||||
|     Row, | ||||
|     Col, | ||||
|     Spinner, | ||||
|     Card, | ||||
|     CardHeader, | ||||
|     CardTitle, | ||||
|     CardBody, | ||||
|     Table, | ||||
|     Progress, | ||||
|     Icon, | ||||
|     Button, | ||||
|     Tooltip | ||||
|     TabContent, | ||||
|     TabPane | ||||
|   } from "@sveltestrap/sveltestrap"; | ||||
|   import { | ||||
|     queryStore, | ||||
|     gql, | ||||
|     getContextClient, | ||||
|     mutationStore, | ||||
|   } from "@urql/svelte"; | ||||
|   import { | ||||
|     init, | ||||
|     convert2uplot, | ||||
|     transformPerNodeDataForRoofline, | ||||
|     scramble, | ||||
|     scrambleNames, | ||||
|   } from "./generic/utils.js"; | ||||
|   import { scaleNumbers } from "./generic/units.js"; | ||||
|   import PlotGrid from "./generic/PlotGrid.svelte"; | ||||
|   import Roofline from "./generic/plots/Roofline.svelte"; | ||||
|   import Pie, { colors } from "./generic/plots/Pie.svelte"; | ||||
|   import Histogram from "./generic/plots/Histogram.svelte"; | ||||
|   import Refresher from "./generic/helper/Refresher.svelte"; | ||||
|   import HistogramSelection from "./generic/select/HistogramSelection.svelte"; | ||||
|  | ||||
|   import StatusDash from "./status/StatusDash.svelte"; | ||||
|   import UsageDash from "./status/UsageDash.svelte"; | ||||
|   import StatisticsDash from "./status/StatisticsDash.svelte"; | ||||
|  | ||||
|   /* Svelte 5 Props */ | ||||
|   let { | ||||
|     cluster | ||||
|     presetCluster | ||||
|   } = $props(); | ||||
|  | ||||
|   /* Const Init */ | ||||
|   const { query: initq } = init(); | ||||
|   const ccconfig = getContext("cc-config"); | ||||
|   const client = getContextClient(); | ||||
|   const paging = { itemsPerPage: 10, page: 1 }; // Top 10 | ||||
|   const topOptions = [ | ||||
|     { key: "totalJobs", label: "Jobs" }, | ||||
|     { key: "totalNodes", label: "Nodes" }, | ||||
|     { key: "totalCores", label: "Cores" }, | ||||
|     { key: "totalAccs", label: "Accelerators" }, | ||||
|   ]; | ||||
|   /*Const Init */ | ||||
|   const useCbColors = getContext("cc-config")?.plot_general_colorblindMode || false | ||||
|  | ||||
|   /* State Init */ | ||||
|   let from = $state(new Date(Date.now() - 5 * 60 * 1000)); | ||||
|   let to = $state(new Date(Date.now())); | ||||
|   let colWidth = $state(0); | ||||
|   let plotWidths = $state([]); | ||||
|   // Histrogram | ||||
|   let isHistogramSelectionOpen = $state(false); | ||||
|   let selectedHistograms = $state(cluster | ||||
|     ? ccconfig[`user_view_histogramMetrics:${cluster}`] || ( ccconfig['user_view_histogramMetrics'] || [] ) | ||||
|     : ccconfig['user_view_histogramMetrics'] || []); | ||||
|   // Bar Gauges | ||||
|   let allocatedNodes = $state({}); | ||||
|   let flopRate = $state({}); | ||||
|   let flopRateUnitPrefix = $state({}); | ||||
|   let flopRateUnitBase = $state({}); | ||||
|   let memBwRate = $state({}); | ||||
|   let memBwRateUnitPrefix = $state({}); | ||||
|   let memBwRateUnitBase = $state({}); | ||||
|   // Pie Charts | ||||
|   let topProjectSelection = $state( | ||||
|     topOptions.find( | ||||
|       (option) => | ||||
|         option.key == | ||||
|         ccconfig[`status_view_selectedTopProjectCategory:${cluster}`], | ||||
|     ) || | ||||
|     topOptions.find( | ||||
|       (option) => option.key == ccconfig.status_view_selectedTopProjectCategory, | ||||
|     ) | ||||
|   ); | ||||
|   let topUserSelection = $state( | ||||
|     topOptions.find( | ||||
|       (option) => | ||||
|         option.key == | ||||
|         ccconfig[`status_view_selectedTopUserCategory:${cluster}`], | ||||
|     ) || | ||||
|     topOptions.find( | ||||
|       (option) => option.key == ccconfig.status_view_selectedTopUserCategory, | ||||
|     ) | ||||
|   ); | ||||
|  | ||||
|   /* Derived */ | ||||
|   // Note: nodeMetrics are requested on configured $timestep resolution | ||||
|   const mainQuery = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ( | ||||
|         $cluster: String! | ||||
|         $filter: [JobFilter!]! | ||||
|         $metrics: [String!] | ||||
|         $from: Time! | ||||
|         $to: Time! | ||||
|         $selectedHistograms: [String!] | ||||
|       ) { | ||||
|         nodeMetrics( | ||||
|           cluster: $cluster | ||||
|           metrics: $metrics | ||||
|           from: $from | ||||
|           to: $to | ||||
|         ) { | ||||
|           host | ||||
|           subCluster | ||||
|           metrics { | ||||
|             name | ||||
|             scope | ||||
|             metric { | ||||
|               timestep | ||||
|               unit { | ||||
|                 base | ||||
|                 prefix | ||||
|               } | ||||
|               series { | ||||
|                 data | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         stats: jobsStatistics(filter: $filter, metrics: $selectedHistograms) { | ||||
|           histDuration { | ||||
|             count | ||||
|             value | ||||
|           } | ||||
|           histNumNodes { | ||||
|             count | ||||
|             value | ||||
|           } | ||||
|           histNumCores { | ||||
|             count | ||||
|             value | ||||
|           } | ||||
|           histNumAccs { | ||||
|             count | ||||
|             value | ||||
|           } | ||||
|           histMetrics { | ||||
|             metric | ||||
|             unit | ||||
|             data { | ||||
|               min | ||||
|               max | ||||
|               count | ||||
|               bin | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         allocatedNodes(cluster: $cluster) { | ||||
|           name | ||||
|           count | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       cluster: cluster, | ||||
|       metrics: ["flops_any", "mem_bw"], // Fixed names for roofline and status bars | ||||
|       from: from.toISOString(), | ||||
|       to: to.toISOString(), | ||||
|       filter: [{ state: ["running"] }, { cluster: { eq: cluster } }], | ||||
|       selectedHistograms: selectedHistograms, | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
|   const topUserQuery = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ( | ||||
|         $filter: [JobFilter!]! | ||||
|         $paging: PageRequest! | ||||
|         $sortBy: SortByAggregate! | ||||
|       ) { | ||||
|         topUser: jobsStatistics( | ||||
|           filter: $filter | ||||
|           page: $paging | ||||
|           sortBy: $sortBy | ||||
|           groupBy: USER | ||||
|         ) { | ||||
|           id | ||||
|           name | ||||
|           totalJobs | ||||
|           totalNodes | ||||
|           totalCores | ||||
|           totalAccs | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       filter: [{ state: ["running"] }, { cluster: { eq: cluster } }], | ||||
|       paging, | ||||
|       sortBy: topUserSelection.key.toUpperCase(), | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
|   const topProjectQuery = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ( | ||||
|         $filter: [JobFilter!]! | ||||
|         $paging: PageRequest! | ||||
|         $sortBy: SortByAggregate! | ||||
|       ) { | ||||
|         topProjects: jobsStatistics( | ||||
|           filter: $filter | ||||
|           page: $paging | ||||
|           sortBy: $sortBy | ||||
|           groupBy: PROJECT | ||||
|         ) { | ||||
|           id | ||||
|           totalJobs | ||||
|           totalNodes | ||||
|           totalCores | ||||
|           totalAccs | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       filter: [{ state: ["running"] }, { cluster: { eq: cluster } }], | ||||
|       paging, | ||||
|       sortBy: topProjectSelection.key.toUpperCase(), | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
|   /* Effects */ | ||||
|   $effect(() => { | ||||
|     if ($initq.data && $mainQuery.data) { | ||||
|       let subClusters = $initq.data.clusters.find( | ||||
|         (c) => c.name == cluster, | ||||
|       ).subClusters; | ||||
|       for (let subCluster of subClusters) { | ||||
|         allocatedNodes[subCluster.name] = | ||||
|           $mainQuery.data.allocatedNodes.find( | ||||
|             ({ name }) => name == subCluster.name, | ||||
|           )?.count || 0; | ||||
|         flopRate[subCluster.name] = | ||||
|           Math.floor( | ||||
|             sumUp($mainQuery.data.nodeMetrics, subCluster.name, "flops_any") * | ||||
|               100, | ||||
|           ) / 100; | ||||
|         flopRateUnitPrefix[subCluster.name] = subCluster.flopRateSimd.unit.prefix; | ||||
|         flopRateUnitBase[subCluster.name] = subCluster.flopRateSimd.unit.base; | ||||
|         memBwRate[subCluster.name] = | ||||
|           Math.floor( | ||||
|             sumUp($mainQuery.data.nodeMetrics, subCluster.name, "mem_bw") * 100, | ||||
|           ) / 100; | ||||
|         memBwRateUnitPrefix[subCluster.name] = | ||||
|           subCluster.memoryBandwidth.unit.prefix; | ||||
|         memBwRateUnitBase[subCluster.name] = subCluster.memoryBandwidth.unit.base; | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   $effect(() => { | ||||
|     updateTopUserConfiguration(topUserSelection.key); | ||||
|   }); | ||||
|  | ||||
|   $effect(() => { | ||||
|     updateTopProjectConfiguration(topProjectSelection.key); | ||||
|   }); | ||||
|  | ||||
|   /* Const Functions */ | ||||
|   const sumUp = (data, subcluster, metric) => | ||||
|     data.reduce( | ||||
|       (sum, node) => | ||||
|         node.subCluster == subcluster | ||||
|           ? sum + | ||||
|             (node.metrics | ||||
|               .find((m) => m.name == metric) | ||||
|               ?.metric.series.reduce( | ||||
|                 (sum, series) => sum + series.data[series.data.length - 1], | ||||
|                 0, | ||||
|               ) || 0) | ||||
|           : sum, | ||||
|       0, | ||||
|     ); | ||||
|  | ||||
|   const updateConfigurationMutation = ({ name, value }) => { | ||||
|     return mutationStore({ | ||||
|       client: client, | ||||
|       query: gql` | ||||
|         mutation ($name: String!, $value: String!) { | ||||
|           updateConfiguration(name: $name, value: $value) | ||||
|         } | ||||
|       `, | ||||
|       variables: { name, value }, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   /* Functions */ | ||||
|   function updateTopUserConfiguration(select) { | ||||
|     if (ccconfig[`status_view_selectedTopUserCategory:${cluster}`] != select) { | ||||
|       updateConfigurationMutation({ | ||||
|         name: `status_view_selectedTopUserCategory:${cluster}`, | ||||
|         value: JSON.stringify(select), | ||||
|       }).subscribe((res) => { | ||||
|         if (res.fetching === false && res.error) { | ||||
|           throw res.error; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function updateTopProjectConfiguration(select) { | ||||
|     if ( | ||||
|       ccconfig[`status_view_selectedTopProjectCategory:${cluster}`] != select | ||||
|     ) { | ||||
|       updateConfigurationMutation({ | ||||
|         name: `status_view_selectedTopProjectCategory:${cluster}`, | ||||
|         value: JSON.stringify(select), | ||||
|       }).subscribe((res) => { | ||||
|         if (res.fetching === false && res.error) { | ||||
|           throw res.error; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <!-- Loading indicator & Refresh --> | ||||
|  | ||||
| <Row cols={{ lg: 3, md: 3, sm: 1 }}> | ||||
|   <Col style=""> | ||||
|     <h4 class="mb-0">Current utilization of cluster "{cluster}"</h4> | ||||
|   </Col> | ||||
|   <Col class="mt-2 mt-md-0 text-md-end"> | ||||
|     <Button | ||||
|       outline | ||||
|       color="secondary" | ||||
|       onclick={() => (isHistogramSelectionOpen = true)} | ||||
|     > | ||||
|       <Icon name="bar-chart-line" /> Select Histograms | ||||
|     </Button> | ||||
|   </Col> | ||||
|   <Col class="mt-2 mt-md-0"> | ||||
|     <Refresher | ||||
|       initially={120} | ||||
|       onRefresh={() => { | ||||
|         from = new Date(Date.now() - 5 * 60 * 1000); | ||||
|         to = new Date(Date.now()); | ||||
|       }} | ||||
|     /> | ||||
|   </Col> | ||||
| </Row> | ||||
| <Row cols={1} class="text-center mt-3"> | ||||
| <Row cols={1} class="mb-2"> | ||||
|   <Col> | ||||
|     {#if $initq.fetching || $mainQuery.fetching} | ||||
|       <Spinner /> | ||||
|     {:else if $initq.error} | ||||
|       <Card body color="danger">{$initq.error.message}</Card> | ||||
|     {:else} | ||||
|       <!-- ... --> | ||||
|     {/if} | ||||
|     <h3 class="mb-0">Current Status of Cluster "{presetCluster.charAt(0).toUpperCase() + presetCluster.slice(1)}"</h3> | ||||
|   </Col> | ||||
| </Row> | ||||
| {#if $mainQuery.error} | ||||
|   <Row cols={1}> | ||||
|     <Col> | ||||
|       <Card body color="danger">{$mainQuery.error.message}</Card> | ||||
|     </Col> | ||||
|   </Row> | ||||
| {/if} | ||||
|  | ||||
| <hr /> | ||||
| <Card class="overflow-auto" style="height: auto;"> | ||||
|   <TabContent> | ||||
|     <TabPane tabId="status-dash" tab="Status" active> | ||||
|       <CardBody> | ||||
|         <StatusDash {presetCluster} {useCbColors} useAltColors></StatusDash> | ||||
|       </CardBody> | ||||
|     </TabPane> | ||||
|  | ||||
| <!-- Gauges & Roofline per Subcluster--> | ||||
|  | ||||
| {#if $initq.data && $mainQuery.data} | ||||
|   {#each $initq.data.clusters.find((c) => c.name == cluster).subClusters as subCluster, i} | ||||
|     <Row cols={{ lg: 2, md: 1 , sm: 1}} class="mb-3 justify-content-center"> | ||||
|       <Col class="px-3"> | ||||
|         <Card class="h-auto mt-1"> | ||||
|           <CardHeader> | ||||
|             <CardTitle class="mb-0">SubCluster "{subCluster.name}"</CardTitle> | ||||
|           </CardHeader> | ||||
|           <CardBody> | ||||
|             <Table borderless> | ||||
|               <tr class="py-2"> | ||||
|                 <th scope="col">Allocated Nodes</th> | ||||
|                 <td style="min-width: 100px;" | ||||
|                   ><div class="col"> | ||||
|                     <Progress | ||||
|                       value={allocatedNodes[subCluster.name]} | ||||
|                       max={subCluster.numberOfNodes} | ||||
|                     /> | ||||
|                   </div></td | ||||
|                 > | ||||
|                 <td | ||||
|                   >{allocatedNodes[subCluster.name]} / {subCluster.numberOfNodes} | ||||
|                   Nodes</td | ||||
|                 > | ||||
|               </tr> | ||||
|               <tr class="py-2"> | ||||
|                 <th scope="col" | ||||
|                   >Flop Rate (Any) <Icon | ||||
|                     name="info-circle" | ||||
|                     class="p-1" | ||||
|                     style="cursor: help;" | ||||
|                     title="Flops[Any] = (Flops[Double] x 2) + Flops[Single]" | ||||
|                   /></th | ||||
|                 > | ||||
|                 <td style="min-width: 100px;" | ||||
|                   ><div class="col"> | ||||
|                     <Progress | ||||
|                       value={flopRate[subCluster.name]} | ||||
|                       max={subCluster.flopRateSimd.value * | ||||
|                         subCluster.numberOfNodes} | ||||
|                     /> | ||||
|                   </div></td | ||||
|                 > | ||||
|                 <td> | ||||
|                   {scaleNumbers( | ||||
|                     flopRate[subCluster.name], | ||||
|                     subCluster.flopRateSimd.value * subCluster.numberOfNodes, | ||||
|                     flopRateUnitPrefix[subCluster.name], | ||||
|                   )}{flopRateUnitBase[subCluster.name]} [Max] | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr class="py-2"> | ||||
|                 <th scope="col">MemBw Rate</th> | ||||
|                 <td style="min-width: 100px;" | ||||
|                   ><div class="col"> | ||||
|                     <Progress | ||||
|                       value={memBwRate[subCluster.name]} | ||||
|                       max={subCluster.memoryBandwidth.value * | ||||
|                         subCluster.numberOfNodes} | ||||
|                     /> | ||||
|                   </div></td | ||||
|                 > | ||||
|                 <td> | ||||
|                   {scaleNumbers( | ||||
|                     memBwRate[subCluster.name], | ||||
|                     subCluster.memoryBandwidth.value * subCluster.numberOfNodes, | ||||
|                     memBwRateUnitPrefix[subCluster.name], | ||||
|                   )}{memBwRateUnitBase[subCluster.name]} [Max] | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </Table> | ||||
|           </CardBody> | ||||
|         </Card> | ||||
|       </Col> | ||||
|       <Col class="px-3 mt-2 mt-lg-0"> | ||||
|         <div bind:clientWidth={plotWidths[i]}> | ||||
|           {#key $mainQuery.data.nodeMetrics} | ||||
|             <Roofline | ||||
|               allowSizeChange | ||||
|               width={plotWidths[i] - 10} | ||||
|               height={300} | ||||
|               subCluster={subCluster} | ||||
|               data={transformPerNodeDataForRoofline( | ||||
|                 $mainQuery.data.nodeMetrics.filter( | ||||
|                   (data) => data.subCluster == subCluster.name, | ||||
|                 ), | ||||
|               )} | ||||
|             /> | ||||
|           {/key} | ||||
|         </div> | ||||
|       </Col> | ||||
|     </Row> | ||||
|   {/each} | ||||
|  | ||||
|   <hr /> | ||||
|  | ||||
|   <!-- User and Project Stats as Pie-Charts --> | ||||
|  | ||||
|   <Row cols={{ lg: 4, md: 2, sm: 1 }}> | ||||
|     <Col class="p-2"> | ||||
|       <div bind:clientWidth={colWidth}> | ||||
|         <h4 class="text-center"> | ||||
|           Top Users on {cluster.charAt(0).toUpperCase() + cluster.slice(1)} | ||||
|         </h4> | ||||
|         {#key $topUserQuery.data} | ||||
|           {#if $topUserQuery.fetching} | ||||
|             <Spinner /> | ||||
|           {:else if $topUserQuery.error} | ||||
|             <Card body color="danger">{$topUserQuery.error.message}</Card> | ||||
|           {:else} | ||||
|             <Pie | ||||
|               canvasId="hpcpie-users" | ||||
|               size={colWidth} | ||||
|               sliceLabel={topUserSelection.label} | ||||
|               quantities={$topUserQuery.data.topUser.map( | ||||
|                 (tu) => tu[topUserSelection.key], | ||||
|               )} | ||||
|               entities={$topUserQuery.data.topUser.map((tu) => scrambleNames ? scramble(tu.id) : tu.id)} | ||||
|             /> | ||||
|           {/if} | ||||
|         {/key} | ||||
|       </div> | ||||
|     </Col> | ||||
|     <Col class="px-4 py-2"> | ||||
|       {#key $topUserQuery.data} | ||||
|         {#if $topUserQuery.fetching} | ||||
|           <Spinner /> | ||||
|         {:else if $topUserQuery.error} | ||||
|           <Card body color="danger">{$topUserQuery.error.message}</Card> | ||||
|         {:else} | ||||
|           <Table> | ||||
|             <tr class="mb-2"> | ||||
|               <th>Legend</th> | ||||
|               <th>User Name</th> | ||||
|               <th | ||||
|                 >Number of | ||||
|                 <select class="p-0" bind:value={topUserSelection}> | ||||
|                   {#each topOptions as option} | ||||
|                     <option value={option}> | ||||
|                       {option.label} | ||||
|                     </option> | ||||
|                   {/each} | ||||
|                 </select> | ||||
|               </th> | ||||
|             </tr> | ||||
|             {#each $topUserQuery.data.topUser as tu, i} | ||||
|               <tr> | ||||
|                 <td><Icon name="circle-fill" style="color: {colors[i]};" /></td> | ||||
|                 <th scope="col" id="topName-{tu.id}" | ||||
|                   ><a | ||||
|                     href="/monitoring/user/{tu.id}?cluster={cluster}&state=running" | ||||
|                     >{scrambleNames ? scramble(tu.id) : tu.id}</a | ||||
|                   ></th | ||||
|                 > | ||||
|                 {#if tu?.name} | ||||
|                   <Tooltip | ||||
|                     target={`topName-${tu.id}`} | ||||
|                     placement="left" | ||||
|                     >{scrambleNames ? scramble(tu.name) : tu.name}</Tooltip | ||||
|                   > | ||||
|                 {/if} | ||||
|                 <td>{tu[topUserSelection.key]}</td> | ||||
|               </tr> | ||||
|             {/each} | ||||
|           </Table> | ||||
|         {/if} | ||||
|       {/key} | ||||
|     </Col> | ||||
|     <Col class="p-2"> | ||||
|       <h4 class="text-center"> | ||||
|         Top Projects on {cluster.charAt(0).toUpperCase() + cluster.slice(1)} | ||||
|       </h4> | ||||
|       {#key $topProjectQuery.data} | ||||
|         {#if $topProjectQuery.fetching} | ||||
|           <Spinner /> | ||||
|         {:else if $topProjectQuery.error} | ||||
|           <Card body color="danger">{$topProjectQuery.error.message}</Card> | ||||
|         {:else} | ||||
|           <Pie | ||||
|             canvasId="hpcpie-projects" | ||||
|             size={colWidth} | ||||
|             sliceLabel={topProjectSelection.label} | ||||
|             quantities={$topProjectQuery.data.topProjects.map( | ||||
|               (tp) => tp[topProjectSelection.key], | ||||
|             )} | ||||
|             entities={$topProjectQuery.data.topProjects.map((tp) => scrambleNames ? scramble(tp.id) : tp.id)} | ||||
|           /> | ||||
|         {/if} | ||||
|       {/key} | ||||
|     </Col> | ||||
|     <Col class="px-4 py-2"> | ||||
|       {#key $topProjectQuery.data} | ||||
|         {#if $topProjectQuery.fetching} | ||||
|           <Spinner /> | ||||
|         {:else if $topProjectQuery.error} | ||||
|           <Card body color="danger">{$topProjectQuery.error.message}</Card> | ||||
|         {:else} | ||||
|           <Table> | ||||
|             <tr class="mb-2"> | ||||
|               <th>Legend</th> | ||||
|               <th>Project Code</th> | ||||
|               <th | ||||
|                 >Number of | ||||
|                 <select class="p-0" bind:value={topProjectSelection}> | ||||
|                   {#each topOptions as option} | ||||
|                     <option value={option}> | ||||
|                       {option.label} | ||||
|                     </option> | ||||
|                   {/each} | ||||
|                 </select> | ||||
|               </th> | ||||
|             </tr> | ||||
|             {#each $topProjectQuery.data.topProjects as tp, i} | ||||
|               <tr> | ||||
|                 <td><Icon name="circle-fill" style="color: {colors[i]};" /></td> | ||||
|                 <th scope="col" | ||||
|                   ><a | ||||
|                     href="/monitoring/jobs/?cluster={cluster}&state=running&project={tp.id}&projectMatch=eq" | ||||
|                     >{scrambleNames ? scramble(tp.id) : tp.id}</a | ||||
|                   ></th | ||||
|                 > | ||||
|                 <td>{tp[topProjectSelection.key]}</td> | ||||
|               </tr> | ||||
|             {/each} | ||||
|           </Table> | ||||
|         {/if} | ||||
|       {/key} | ||||
|     </Col> | ||||
|   </Row> | ||||
|  | ||||
|   <hr class="my-2" /> | ||||
|  | ||||
|   <!-- Static Stats as Histograms : Running Duration && Allocated Hardware Counts--> | ||||
|  | ||||
|   <Row cols={{ lg: 2, md: 1 }}> | ||||
|     <Col class="p-2"> | ||||
|       {#key $mainQuery.data.stats} | ||||
|         <Histogram | ||||
|           data={convert2uplot($mainQuery.data.stats[0].histDuration)} | ||||
|           title="Duration Distribution" | ||||
|           xlabel="Current Job Runtimes" | ||||
|           xunit="Runtime" | ||||
|           ylabel="Number of Jobs" | ||||
|           yunit="Jobs" | ||||
|           usesBins | ||||
|           xtime | ||||
|         /> | ||||
|       {/key} | ||||
|     </Col> | ||||
|     <Col class="p-2"> | ||||
|       {#key $mainQuery.data.stats} | ||||
|         <Histogram | ||||
|           data={convert2uplot($mainQuery.data.stats[0].histNumNodes)} | ||||
|           title="Number of Nodes Distribution" | ||||
|           xlabel="Allocated Nodes" | ||||
|           xunit="Nodes" | ||||
|           ylabel="Number of Jobs" | ||||
|           yunit="Jobs" | ||||
|         /> | ||||
|       {/key} | ||||
|     </Col> | ||||
|   </Row> | ||||
|   <Row cols={{ lg: 2, md: 1 }}> | ||||
|     <Col class="p-2"> | ||||
|       {#key $mainQuery.data.stats} | ||||
|         <Histogram | ||||
|           data={convert2uplot($mainQuery.data.stats[0].histNumCores)} | ||||
|           title="Number of Cores Distribution" | ||||
|           xlabel="Allocated Cores" | ||||
|           xunit="Cores" | ||||
|           ylabel="Number of Jobs" | ||||
|           yunit="Jobs" | ||||
|         /> | ||||
|       {/key} | ||||
|     </Col> | ||||
|     <Col class="p-2"> | ||||
|       {#key $mainQuery.data.stats} | ||||
|         <Histogram | ||||
|           data={convert2uplot($mainQuery.data.stats[0].histNumAccs)} | ||||
|           title="Number of Accelerators Distribution" | ||||
|           xlabel="Allocated Accs" | ||||
|           xunit="Accs" | ||||
|           ylabel="Number of Jobs" | ||||
|           yunit="Jobs" | ||||
|         /> | ||||
|       {/key} | ||||
|     </Col> | ||||
|   </Row> | ||||
|  | ||||
|   <hr class="my-2" /> | ||||
|  | ||||
|   <!-- Selectable Stats as Histograms : Average Values of Running Jobs --> | ||||
|  | ||||
|   {#if selectedHistograms} | ||||
|     <!-- Note: Ignore '#snippet' Error in IDE --> | ||||
|     {#snippet gridContent(item)} | ||||
|       <Histogram | ||||
|         data={convert2uplot(item.data)} | ||||
|         title="Distribution of '{item.metric}' averages" | ||||
|         xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`} | ||||
|         xunit={item.unit} | ||||
|         ylabel="Number of Jobs" | ||||
|         yunit="Jobs" | ||||
|         usesBins | ||||
|       /> | ||||
|     {/snippet} | ||||
|     <TabPane tabId="usage-dash" tab="Usage"> | ||||
|       <CardBody> | ||||
|         <UsageDash {presetCluster} {useCbColors}></UsageDash> | ||||
|       </CardBody> | ||||
|     </TabPane> | ||||
|      | ||||
|     {#key $mainQuery.data.stats[0].histMetrics} | ||||
|       <PlotGrid | ||||
|         items={$mainQuery.data.stats[0].histMetrics} | ||||
|         itemsPerRow={2} | ||||
|         {gridContent} | ||||
|       /> | ||||
|     {/key} | ||||
|   {/if} | ||||
| {/if} | ||||
|  | ||||
| <HistogramSelection | ||||
|   {cluster} | ||||
|   bind:isOpen={isHistogramSelectionOpen} | ||||
|   presetSelectedHistograms={selectedHistograms} | ||||
|   applyChange={(newSelection) => { | ||||
|     selectedHistograms = [...newSelection]; | ||||
|   }} | ||||
| /> | ||||
|     <TabPane tabId="metric-dash" tab="Statistics"> | ||||
|       <CardBody> | ||||
|         <StatisticsDash {presetCluster} {useCbColors}></StatisticsDash> | ||||
|       </CardBody> | ||||
|     </TabPane> | ||||
|   </TabContent> | ||||
| </Card> | ||||
| @@ -404,6 +404,7 @@ | ||||
|   cluster={selectedCluster} | ||||
|   bind:isOpen={isHistogramSelectionOpen} | ||||
|   presetSelectedHistograms={selectedHistograms} | ||||
|   configName="user_view_histogramMetrics" | ||||
|   applyChange={(newSelection) => { | ||||
|     selectedHistogramsBuffer[selectedCluster || 'all'] = [...newSelection]; | ||||
|   }} | ||||
|   | ||||
| @@ -26,7 +26,7 @@ | ||||
|   /* Svelte 5 Props */ | ||||
|   let { | ||||
|     matchedCompareJobs = $bindable(0), | ||||
|     metrics = ccconfig?.plot_list_selectedMetrics, | ||||
|     metrics = getContext("cc-config")?.plot_list_selectedMetrics, | ||||
|     filterBuffer = [], | ||||
|   } = $props(); | ||||
|  | ||||
|   | ||||
| @@ -44,7 +44,7 @@ | ||||
|  | ||||
|   /* Const Init */ | ||||
|   const clusterCockpitConfig = getContext("cc-config"); | ||||
|   const lineWidth = clusterCockpitConfig.plot_general_lineWidth / window.devicePixelRatio; | ||||
|   const lineWidth = clusterCockpitConfig?.plot_general_lineWidth / window.devicePixelRatio || 2; | ||||
|   const cbmode = clusterCockpitConfig?.plot_general_colorblindMode || false; | ||||
|  | ||||
|   // UPLOT SERIES INIT // | ||||
|   | ||||
| @@ -14,28 +14,59 @@ | ||||
| --> | ||||
|  | ||||
| <script module> | ||||
|   // http://tsitsul.in/blog/coloropt/ : 12 colors normal | ||||
|   export const colors = [ | ||||
|     'rgb(235,172,35)', | ||||
|     'rgb(184,0,88)', | ||||
|     'rgb(0,140,249)', | ||||
|     'rgb(0,110,0)', | ||||
|     'rgb(0,187,173)', | ||||
|     'rgb(209,99,230)', | ||||
|     'rgb(178,69,2)', | ||||
|     'rgb(255,146,135)', | ||||
|     'rgb(89,84,214)', | ||||
|     'rgb(0,198,248)', | ||||
|     'rgb(135,133,0)', | ||||
|     'rgb(0,167,108)', | ||||
|     'rgb(189,189,189)' | ||||
|   ]; | ||||
|   export const colors = { | ||||
|     // https://www.learnui.design/tools/data-color-picker.html#divergent: 11, Shallow Green-Red | ||||
|     default: [ | ||||
|       "#00876c", | ||||
|       "#449c6e", | ||||
|       "#70af6f", | ||||
|       "#9bc271", | ||||
|       "#c8d377", | ||||
|       "#f7e382", | ||||
|       "#f6c468", | ||||
|       "#f3a457", | ||||
|       "#ed834e", | ||||
|       "#e3614d", | ||||
|       "#d43d51", | ||||
|     ], | ||||
|     // https://www.learnui.design/tools/data-color-picker.html#palette: 12, Colorwheel-Like | ||||
|     alternative: [ | ||||
|       "#0022bb", | ||||
|       "#ba0098", | ||||
|       "#fa0066", | ||||
|       "#ff6234", | ||||
|       "#ffae00", | ||||
|       "#b1af00", | ||||
|       "#67a630", | ||||
|       "#009753", | ||||
|       "#00836c", | ||||
|       "#006d77", | ||||
|       "#005671", | ||||
|       "#003f5c", | ||||
|     ], | ||||
|     // http://tsitsul.in/blog/coloropt/ : 12 colors normal | ||||
|     colorblind: [ | ||||
|       'rgb(235,172,35)', | ||||
|       'rgb(184,0,88)', | ||||
|       'rgb(0,140,249)', | ||||
|       'rgb(0,110,0)', | ||||
|       'rgb(0,187,173)', | ||||
|       'rgb(209,99,230)', | ||||
|       'rgb(178,69,2)', | ||||
|       'rgb(255,146,135)', | ||||
|       'rgb(89,84,214)', | ||||
|       'rgb(0,198,248)', | ||||
|       'rgb(135,133,0)', | ||||
|       'rgb(0,167,108)', | ||||
|       'rgb(189,189,189)', | ||||
|     ] | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <script> | ||||
|   /* Ignore Double Script Section Error in IDE */ | ||||
|   // Ignore VSC IDE "One Instance Level Script" Error | ||||
|   import { onMount, getContext } from "svelte"; | ||||
|   import Chart from 'chart.js/auto'; | ||||
|   import { onMount } from 'svelte'; | ||||
|  | ||||
|   /* Svelte 5 Props */ | ||||
|   let { | ||||
| @@ -45,21 +76,11 @@ | ||||
|     quantities, | ||||
|     entities, | ||||
|     displayLegend = false, | ||||
|     useAltColors = false, | ||||
|   } = $props(); | ||||
|  | ||||
|   /* Const Init */ | ||||
|   const data = { | ||||
|     labels: entities, | ||||
|     datasets: [ | ||||
|       { | ||||
|         label: sliceLabel, | ||||
|         data: quantities, | ||||
|         fill: 1, | ||||
|         backgroundColor: colors.slice(0, quantities.length) | ||||
|       } | ||||
|     ] | ||||
|   }; | ||||
|  | ||||
|   const useCbColors = getContext("cc-config")?.plot_general_colorblindMode || false | ||||
|   const options = {  | ||||
|     maintainAspectRatio: false, | ||||
|     animation: false, | ||||
| @@ -70,6 +91,31 @@ | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   /* Derived */ | ||||
|   const colorPalette = $derived.by(() => { | ||||
|     let c; | ||||
|     if (useCbColors) { | ||||
|       c = [...colors['colorblind']]; | ||||
|     } else if (useAltColors) { | ||||
|       c = [...colors['alternative']]; | ||||
|     } else { | ||||
|       c = [...colors['default']]; | ||||
|     } | ||||
|     return c.slice(0, quantities.length); | ||||
|   }) | ||||
|  | ||||
|   const data = $derived({ | ||||
|     labels: entities, | ||||
|     datasets: [ | ||||
|       { | ||||
|         label: sliceLabel, | ||||
|         data: quantities, | ||||
|         fill: 1, | ||||
|         backgroundColor: colorPalette, | ||||
|       } | ||||
|     ] | ||||
|   }); | ||||
|  | ||||
|   /* On Mount */ | ||||
|   onMount(() => { | ||||
|     new Chart( | ||||
| @@ -84,7 +130,7 @@ | ||||
| </script> | ||||
|  | ||||
| <!-- <div style="width: 500px;"><canvas id="dimensions"></canvas></div><br/> --> | ||||
| <div class="chart-container" style="--container-width: {size}; --container-height: {size}"> | ||||
| <div class="chart-container" style="--container-width: {size}px; --container-height: {size}px"> | ||||
|   <canvas id={canvasId}></canvas> | ||||
| </div> | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,6 @@ | ||||
|  | ||||
|   Properties: | ||||
|   - `data [null, [], []]`: Roofline Data Structure, see below for details [Default: null] | ||||
|   - `renderTime Bool?`: If time information should be rendered as colored dots [Default: false] | ||||
|   - `allowSizeChange Bool?`: If dimensions of rendered plot can change [Default: false] | ||||
|   - `subCluster GraphQL.SubCluster?`: SubCluster Object; contains required topology information [Default: null] | ||||
|   - `width Number?`: Plot width (reactively adaptive) [Default: 600] | ||||
| @@ -21,19 +20,22 @@ | ||||
|   - `data[2] = [0.1, 0.15, 0.2, ...]` | ||||
|     - Color Code: Time Information (Floats from 0 to 1) (Optional) | ||||
| --> | ||||
|  | ||||
| <script> | ||||
|   import uPlot from "uplot"; | ||||
|   import { formatNumber } from "../units.js"; | ||||
|   import { onMount, onDestroy } from "svelte"; | ||||
|   import { Card } from "@sveltestrap/sveltestrap"; | ||||
|   import { roundTwoDigits } from "../units.js"; | ||||
|  | ||||
|   /* Svelte 5 Props */ | ||||
|   let { | ||||
|     data = null, | ||||
|     renderTime = false, | ||||
|     allowSizeChange = false, | ||||
|     roofData = null, | ||||
|     jobsData = null, | ||||
|     nodesData = null, | ||||
|     cluster = null, | ||||
|     subCluster = null, | ||||
|     allowSizeChange = false, | ||||
|     useColors = true, | ||||
|     width = 600, | ||||
|     height = 380, | ||||
|   } = $props(); | ||||
| @@ -54,8 +56,27 @@ | ||||
|     if (allowSizeChange) sizeChanged(width, height); | ||||
|   }); | ||||
|  | ||||
|   // Copied Example Vars for Uplot Bubble | ||||
|   // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/isPointInPath | ||||
|   let qt; | ||||
|   let hRect; | ||||
|   let pxRatio; | ||||
|   function setPxRatio() { | ||||
|     pxRatio = uPlot.pxRatio; | ||||
|   } | ||||
|   setPxRatio(); | ||||
|   window.addEventListener('dppxchange', setPxRatio); | ||||
|   // let minSize = 6; | ||||
|   let maxSize = 60; | ||||
|   // let maxArea = Math.PI * (maxSize / 2) ** 2; | ||||
|   // let minArea = Math.PI * (minSize / 2) ** 2; | ||||
|  | ||||
|   /* Functions */ | ||||
|   // Helper | ||||
|   function pointWithin(px, py, rlft, rtop, rrgt, rbtm) { | ||||
|     return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; | ||||
|   } | ||||
|  | ||||
|   function getGradientR(x) { | ||||
|     if (x < 0.5) return 0; | ||||
|     if (x > 0.75) return 255; | ||||
| @@ -74,8 +95,9 @@ | ||||
|     x = 1.0 - (x - 0.25) * 4.0; | ||||
|     return Math.floor(x * 255.0); | ||||
|   } | ||||
|   function getRGB(c) { | ||||
|     return `rgb(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)})`; | ||||
|   function getRGB(c, transparent = false) { | ||||
|     if (transparent) return `rgba(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)}, 0.5)`; | ||||
|     else return `rgb(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)})`; | ||||
|   } | ||||
|   function nearestThousand(num) { | ||||
|     return Math.ceil(num / 1000) * 1000; | ||||
| @@ -89,126 +111,492 @@ | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   // Dot Renderers | ||||
|   const drawColorPoints = (u, seriesIdx, idx0, idx1) => { | ||||
|     const size = 5 * devicePixelRatio; | ||||
|     uPlot.orient( | ||||
|       u, | ||||
|       seriesIdx, | ||||
|       ( | ||||
|         series, | ||||
|         dataX, | ||||
|         dataY, | ||||
|         scaleX, | ||||
|         scaleY, | ||||
|         valToPosX, | ||||
|         valToPosY, | ||||
|         xOff, | ||||
|         yOff, | ||||
|         xDim, | ||||
|         yDim, | ||||
|         moveTo, | ||||
|         lineTo, | ||||
|         rect, | ||||
|         arc, | ||||
|       ) => { | ||||
|   // quadratic scaling (px area) | ||||
|   // function getSize(value, minValue, maxValue) { | ||||
|   //   let pct = value / maxValue; | ||||
|   //   // clamp to min area | ||||
|   //   //let area = Math.max(maxArea * pct, minArea); | ||||
|   //   let area = maxArea * pct; | ||||
|   //   return Math.sqrt(area / Math.PI) * 2; | ||||
|   // } | ||||
|  | ||||
|   // function getSizeMinMax(u) { | ||||
|   //   let minValue = Infinity; | ||||
|   //   let maxValue = -Infinity; | ||||
|   //   for (let i = 1; i < u.series.length; i++) { | ||||
|   //     let sizeData = u.data[i][2]; | ||||
|   //     for (let j = 0; j < sizeData.length; j++) { | ||||
|   //       minValue = Math.min(minValue, sizeData[j]); | ||||
|   //       maxValue = Math.max(maxValue, sizeData[j]); | ||||
|   //     } | ||||
|   //   } | ||||
|   //   return [minValue, maxValue]; | ||||
|   // } | ||||
|  | ||||
|   // Quadtree Object (TODO: Split and Import) | ||||
|   class Quadtree { | ||||
|     constructor (x, y, w, h, l) { | ||||
|       let t = this; | ||||
|       t.x = x; | ||||
|       t.y = y; | ||||
|       t.w = w; | ||||
|       t.h = h; | ||||
|       t.l = l || 0; | ||||
|       t.o = []; | ||||
|       t.q = null; | ||||
|       t.MAX_OBJECTS = 10; | ||||
|       t.MAX_LEVELS  = 4; | ||||
|     }; | ||||
|  | ||||
|     get quadtree() { | ||||
|       return "Implement me!"; | ||||
|     } | ||||
|  | ||||
|     split() { | ||||
|       let t = this, | ||||
|         x = t.x, | ||||
|         y = t.y, | ||||
|         w = t.w / 2, | ||||
|         h = t.h / 2, | ||||
|         l = t.l + 1; | ||||
|  | ||||
|       t.q = [ | ||||
|         // top right | ||||
|         new Quadtree(x + w, y,     w, h, l), | ||||
|         // top left | ||||
|         new Quadtree(x,     y,     w, h, l), | ||||
|         // bottom left | ||||
|         new Quadtree(x,     y + h, w, h, l), | ||||
|         // bottom right | ||||
|         new Quadtree(x + w, y + h, w, h, l), | ||||
|       ]; | ||||
|     }; | ||||
|  | ||||
|     quads(x, y, w, h, cb) { | ||||
|       let t        = this, | ||||
|       q            = t.q, | ||||
|       hzMid        = t.x + t.w / 2, | ||||
|       vtMid        = t.y + t.h / 2, | ||||
|       startIsNorth = y     < vtMid, | ||||
|       startIsWest  = x     < hzMid, | ||||
|       endIsEast    = x + w > hzMid, | ||||
|       endIsSouth   = y + h > vtMid; | ||||
|  | ||||
|       // top-right quad | ||||
|       startIsNorth && endIsEast && cb(q[0]); | ||||
|       // top-left quad | ||||
|       startIsWest && startIsNorth && cb(q[1]); | ||||
|       // bottom-left quad | ||||
|       startIsWest && endIsSouth && cb(q[2]); | ||||
|       // bottom-right quad | ||||
|       endIsEast && endIsSouth && cb(q[3]); | ||||
|     }; | ||||
|  | ||||
|     add(o) { | ||||
|       let t = this; | ||||
|  | ||||
|       if (t.q != null) { | ||||
|         t.quads(o.x, o.y, o.w, o.h, q => { | ||||
|           q.add(o); | ||||
|         }); | ||||
|       } | ||||
|       else { | ||||
|         let os = t.o; | ||||
|  | ||||
|         os.push(o); | ||||
|  | ||||
|         if (os.length > t.MAX_OBJECTS && t.l < t.MAX_LEVELS) { | ||||
|           t.split(); | ||||
|  | ||||
|           for (let i = 0; i < os.length; i++) { | ||||
|             let oi = os[i]; | ||||
|  | ||||
|             t.quads(oi.x, oi.y, oi.w, oi.h, q => { | ||||
|               q.add(oi); | ||||
|             }); | ||||
|           } | ||||
|  | ||||
|           t.o.length = 0; | ||||
|         } | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     get(x, y, w, h, cb) { | ||||
|       let t = this; | ||||
|       let os = t.o; | ||||
|  | ||||
|       for (let i = 0; i < os.length; i++) | ||||
|         cb(os[i]); | ||||
|  | ||||
|       if (t.q != null) { | ||||
|         t.quads(x, y, w, h, q => { | ||||
|           q.get(x, y, w, h, cb); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     clear() { | ||||
|       this.o.length = 0; | ||||
|       this.q = null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Dot Renderer | ||||
|   const makeDrawPoints = (opts) => { | ||||
|     let {/*size, disp,*/ transparentFill, each = () => {}} = opts; | ||||
|     const sizeBase = 6 * pxRatio; | ||||
|  | ||||
|     return (u, seriesIdx, idx0, idx1) => { | ||||
|       uPlot.orient(u, seriesIdx, (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect, arc) => { | ||||
|         let d = u.data[seriesIdx]; | ||||
|         let strokeWidth = 1; | ||||
|         let deg360 = 2 * Math.PI; | ||||
|         /* Alt.: Sizes based on other Data Rows */ | ||||
|         // let sizes = disp.size.values(u, seriesIdx, idx0, idx1); | ||||
|  | ||||
|         u.ctx.save(); | ||||
|         u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); | ||||
|         u.ctx.clip(); | ||||
|         u.ctx.lineWidth = strokeWidth; | ||||
|          | ||||
|         // todo: this depends on direction & orientation | ||||
|         // todo: calc once per redraw, not per path | ||||
|         let filtLft = u.posToVal(-maxSize / 2, scaleX.key); | ||||
|         let filtRgt = u.posToVal(u.bbox.width / pxRatio + maxSize / 2, scaleX.key); | ||||
|         let filtBtm = u.posToVal(u.bbox.height / pxRatio + maxSize / 2, scaleY.key); | ||||
|         let filtTop = u.posToVal(-maxSize / 2, scaleY.key); | ||||
|  | ||||
|         for (let i = 0; i < d[0].length; i++) { | ||||
|           let p = new Path2D(); | ||||
|           if (useColors) { | ||||
|             u.ctx.strokeStyle = "rgb(0, 0, 0)"; | ||||
|             // Jobs: Color based on Duration | ||||
|             if (jobsData) { | ||||
|               //u.ctx.strokeStyle = getRGB(u.data[2][i]); | ||||
|               u.ctx.fillStyle = getRGB(u.data[2][i], transparentFill); | ||||
|             // Nodes: Color based on Idle vs. Allocated | ||||
|             } else if (nodesData) { | ||||
|               // console.log('In Plot Handler NodesData', nodesData) | ||||
|               if (nodesData[i]?.nodeState == "idle") { | ||||
|                 //u.ctx.strokeStyle = "rgb(0, 0, 255)"; | ||||
|                 u.ctx.fillStyle = "rgba(0, 0, 255, 0.5)"; | ||||
|               } else if (nodesData[i]?.nodeState == "allocated") { | ||||
|                 //u.ctx.strokeStyle = "rgb(0, 255, 0)"; | ||||
|                 u.ctx.fillStyle = "rgba(0, 255, 0, 0.5)"; | ||||
|               } else if (nodesData[i]?.nodeState == "notindb") { | ||||
|                 //u.ctx.strokeStyle = "rgb(0, 0, 0)"; | ||||
|                 u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; | ||||
|               } else { // Fallback: All other DEFINED states | ||||
|                 //u.ctx.strokeStyle = "rgb(255, 0, 0)"; | ||||
|                 u.ctx.fillStyle = "rgba(255, 0, 0, 0.5)"; | ||||
|               } | ||||
|             } | ||||
|           } else { | ||||
|             // No Colors: Use Black | ||||
|             u.ctx.strokeStyle = "rgb(0, 0, 0)"; | ||||
|             u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; | ||||
|           } | ||||
|  | ||||
|           // Get Values | ||||
|           let xVal = d[0][i]; | ||||
|           let yVal = d[1][i]; | ||||
|           u.ctx.strokeStyle = getRGB(u.data[2][i]); | ||||
|           u.ctx.fillStyle = getRGB(u.data[2][i]); | ||||
|           if ( | ||||
|             xVal >= scaleX.min && | ||||
|             xVal <= scaleX.max && | ||||
|             yVal >= scaleY.min && | ||||
|             yVal <= scaleY.max | ||||
|           ) { | ||||
|  | ||||
|           // Calc Size; Alt.: size = sizes[i] * pxRatio | ||||
|           let size = 1; | ||||
|  | ||||
|           // Jobs: Size based on Resourcecount | ||||
|           if (jobsData) { | ||||
|             const scaling = jobsData[i].numNodes > 12 | ||||
|               ? 24 // Capped Dot Size  | ||||
|               : jobsData[i].numNodes > 1 | ||||
|                 ? jobsData[i].numNodes * 2 // MultiNode Scaling | ||||
|                 : jobsData[i]?.numAcc ? jobsData[i].numAcc : jobsData[i].numNodes * 2 // Single Node or Scale by Accs | ||||
|             size = sizeBase + scaling | ||||
|           // Nodes: Size based on Jobcount | ||||
|           } else if (nodesData) { | ||||
|             size = sizeBase + (nodesData[i]?.numJobs * 1.5) // Max Jobs Scale: 8 * 1.5 = 12 | ||||
|           }; | ||||
|            | ||||
|           if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) { | ||||
|             let cx = valToPosX(xVal, scaleX, xDim, xOff); | ||||
|             let cy = valToPosY(yVal, scaleY, yDim, yOff); | ||||
|  | ||||
|             p.moveTo(cx + size / 2, cy); | ||||
|             arc(p, cx, cy, size / 2, 0, deg360); | ||||
|             u.ctx.moveTo(cx + size/2, cy); | ||||
|             u.ctx.beginPath(); | ||||
|             u.ctx.arc(cx, cy, size/2, 0, deg360); | ||||
|             u.ctx.fill(); | ||||
|             u.ctx.stroke(); | ||||
|  | ||||
|             each(u, seriesIdx, i, | ||||
|               cx - size/2 - strokeWidth/2, | ||||
|               cy - size/2 - strokeWidth/2, | ||||
|               size + strokeWidth, | ||||
|               size + strokeWidth | ||||
|             ); | ||||
|           } | ||||
|           u.ctx.fill(p); | ||||
|         } | ||||
|       }, | ||||
|     ); | ||||
|     return null; | ||||
|         u.ctx.restore(); | ||||
|       }); | ||||
|       return null; | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   const drawPoints = (u, seriesIdx, idx0, idx1) => { | ||||
|     const size = 5 * devicePixelRatio; | ||||
|     uPlot.orient( | ||||
|       u, | ||||
|       seriesIdx, | ||||
|       ( | ||||
|         series, | ||||
|         dataX, | ||||
|         dataY, | ||||
|         scaleX, | ||||
|         scaleY, | ||||
|         valToPosX, | ||||
|         valToPosY, | ||||
|         xOff, | ||||
|         yOff, | ||||
|         xDim, | ||||
|         yDim, | ||||
|         moveTo, | ||||
|         lineTo, | ||||
|         rect, | ||||
|         arc, | ||||
|       ) => { | ||||
|         let d = u.data[seriesIdx]; | ||||
|         u.ctx.strokeStyle = getRGB(0); | ||||
|         u.ctx.fillStyle = getRGB(0); | ||||
|         let deg360 = 2 * Math.PI; | ||||
|         let p = new Path2D(); | ||||
|         for (let i = 0; i < d[0].length; i++) { | ||||
|           let xVal = d[0][i]; | ||||
|           let yVal = d[1][i]; | ||||
|           if ( | ||||
|             xVal >= scaleX.min && | ||||
|             xVal <= scaleX.max && | ||||
|             yVal >= scaleY.min && | ||||
|             yVal <= scaleY.max | ||||
|           ) { | ||||
|             let cx = valToPosX(xVal, scaleX, xDim, xOff); | ||||
|             let cy = valToPosY(yVal, scaleY, yDim, yOff); | ||||
|             p.moveTo(cx + size / 2, cy); | ||||
|             arc(p, cx, cy, size / 2, 0, deg360); | ||||
|   let drawPoints = makeDrawPoints({ | ||||
|     // disp: { | ||||
|     //   size: { | ||||
|     //     // unit: 3, // raw CSS pixels | ||||
|     //     //	discr: true, | ||||
|     //     values: (u, seriesIdx, idx0, idx1) => { | ||||
|     //       /* Func to get sizes from additional subSeries [series][2...x] ([0,1] is [x,y]) */ | ||||
|     //       // TODO: only run once per setData() call | ||||
|     //       let [minValue, maxValue] = getSizeMinMax(u); | ||||
|     //       return u.data[seriesIdx][2].map(v => getSize(v, minValue, maxValue)); | ||||
|     //     }, | ||||
|     //   }, | ||||
|     // }, | ||||
|     transparentFill: true, | ||||
|     each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => { | ||||
|       // we get back raw canvas coords (included axes & padding). translate to the plotting area origin | ||||
|       lft -= u.bbox.left; | ||||
|       top -= u.bbox.top; | ||||
|       qt.add({x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx}); | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   const legendValues = (u, seriesIdx, dataIdx) => { | ||||
|     // when data null, it's initial schema probe (also u.status == 0) | ||||
|     if (u.data == null || dataIdx == null || hRect == null || hRect.sidx != seriesIdx) { | ||||
|       return { | ||||
|         "Intensity [FLOPS/Byte]": '-', | ||||
|         "":'', | ||||
|         "Performace [GFLOPS]": '-' | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       "Intensity [FLOPS/Byte]": roundTwoDigits(u.data[seriesIdx][0][dataIdx]), | ||||
|       "":'', | ||||
|       "Performace [GFLOPS]": roundTwoDigits(u.data[seriesIdx][1][dataIdx]), | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   // Tooltip Plugin | ||||
|   function tooltipPlugin({onclick, getLegendData, shiftX = 10, shiftY = 10}) { | ||||
|     let tooltipLeftOffset = 0; | ||||
|     let tooltipTopOffset = 0; | ||||
|  | ||||
|     const tooltip = document.createElement("div"); | ||||
|  | ||||
|     // Build Manual Class By Styles | ||||
|     tooltip.style.fontSize = "10pt"; | ||||
|     tooltip.style.position = "absolute"; | ||||
|     tooltip.style.background = "#fcfcfc"; | ||||
|     tooltip.style.display = "none"; | ||||
|     tooltip.style.border = "2px solid black"; | ||||
|     tooltip.style.padding = "4px"; | ||||
|     tooltip.style.pointerEvents = "none"; | ||||
|     tooltip.style.zIndex = "100"; | ||||
|     tooltip.style.whiteSpace = "pre"; | ||||
|     tooltip.style.fontFamily = "monospace"; | ||||
|  | ||||
|     const tipSeriesIdx = 1; // Scatter: Series IDX is always 1 | ||||
|     let tipDataIdx = null; | ||||
|  | ||||
|     // const fmtDate = uPlot.fmtDate("{M}/{D}/{YY} {h}:{mm}:{ss} {AA}"); | ||||
|     let over; | ||||
|     let tooltipVisible = false; | ||||
|  | ||||
|     function showTooltip() { | ||||
|       if (!tooltipVisible) { | ||||
|         tooltip.style.display = "block"; | ||||
|         over.style.cursor = "pointer"; | ||||
|         tooltipVisible = true; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function hideTooltip() { | ||||
|       if (tooltipVisible) { | ||||
|         tooltip.style.display = "none"; | ||||
|         over.style.cursor = null; | ||||
|         tooltipVisible = false; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function setTooltip(u, i) { | ||||
|       showTooltip(); | ||||
|  | ||||
|       let top = u.valToPos(u.data[tipSeriesIdx][1][i], 'y'); | ||||
|       let lft = u.valToPos(u.data[tipSeriesIdx][0][i], 'x'); | ||||
|  | ||||
|       tooltip.style.top  = (tooltipTopOffset  + top + shiftX) + "px"; | ||||
|       tooltip.style.left = (tooltipLeftOffset + lft + shiftY) + "px"; | ||||
|  | ||||
|       if (useColors) { | ||||
|         // Jobs: Color based on Duration | ||||
|         if (jobsData) { | ||||
|           tooltip.style.borderColor = getRGB(u.data[2][i]); | ||||
|         // Nodes: Color based on Idle vs. Allocated | ||||
|         } else if (nodesData) { | ||||
|           if (nodesData[i]?.nodeState == "idle") { | ||||
|             tooltip.style.borderColor = "rgb(0, 0, 255)"; | ||||
|           } else if (nodesData[i]?.nodeState == "allocated") { | ||||
|             tooltip.style.borderColor = "rgb(0, 255, 0)"; | ||||
|           } else if (nodesData[i]?.nodeState == "notindb") { // Missing from DB table | ||||
|             tooltip.style.borderColor = "rgb(0, 0, 0)"; | ||||
|           } else { // Fallback: All other DEFINED states | ||||
|             tooltip.style.borderColor = "rgb(255, 0, 0)"; | ||||
|           } | ||||
|         } | ||||
|         u.ctx.fill(p); | ||||
|       }, | ||||
|     ); | ||||
|     return null; | ||||
|   }; | ||||
|       } else { | ||||
|         // No Colors: Use Black | ||||
|         tooltip.style.borderColor = "rgb(0, 0, 0)"; | ||||
|       } | ||||
|  | ||||
|       if (jobsData) { | ||||
|         tooltip.textContent = ( | ||||
|           // Tooltip Content as String for Job | ||||
|           `Job ID: ${getLegendData(u, i).jobId}\nRuntime: ${getLegendData(u, i).duration}\nNodes: ${getLegendData(u, i).numNodes}${getLegendData(u, i)?.numAcc?`\nAccelerators: ${getLegendData(u, i).numAcc}`:''}` | ||||
|         ); | ||||
|       } else if (nodesData && useColors) { | ||||
|         tooltip.textContent = ( | ||||
|           // Tooltip Content as String for Node | ||||
|           `Host: ${getLegendData(u, i).nodeName}\nState: ${getLegendData(u, i).nodeState}\nJobs: ${getLegendData(u, i).numJobs}` | ||||
|         ); | ||||
|       } else if (nodesData && !useColors) { | ||||
|         tooltip.textContent = ( | ||||
|           // Tooltip Content as String for Node | ||||
|           `Host: ${getLegendData(u, i).nodeName}\nJobs: ${getLegendData(u, i).numJobs}` | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       hooks: { | ||||
|         ready: [ | ||||
|           u => { | ||||
|             over = u.over; | ||||
|             tooltipLeftOffset = parseFloat(over.style.left); | ||||
|             tooltipTopOffset = parseFloat(over.style.top); | ||||
|             u.root.querySelector(".u-wrap").appendChild(tooltip); | ||||
|  | ||||
|             let clientX; | ||||
|             let clientY; | ||||
|  | ||||
|             over.addEventListener("mousedown", e => { | ||||
|               clientX = e.clientX; | ||||
|               clientY = e.clientY; | ||||
|             }); | ||||
|  | ||||
|             over.addEventListener("mouseup", e => { | ||||
|               // clicked in-place | ||||
|               if (e.clientX == clientX && e.clientY == clientY) { | ||||
|                 if (tipDataIdx != null) { | ||||
|                   onclick(u, tipDataIdx); | ||||
|                 } | ||||
|               } | ||||
|             }); | ||||
|           } | ||||
|         ], | ||||
|         setCursor: [ | ||||
|           u => { | ||||
|             let i = u.legend.idxs[1]; | ||||
|             if (i != null) { | ||||
|               tipDataIdx = i; | ||||
|               setTooltip(u, i); | ||||
|             } else { | ||||
|               tipDataIdx = null; | ||||
|               hideTooltip(); | ||||
|             } | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   // Main Functions | ||||
|   function sizeChanged() { | ||||
|     if (timeoutId != null) clearTimeout(timeoutId); | ||||
|  | ||||
|     timeoutId = setTimeout(() => { | ||||
|       timeoutId = null; | ||||
|       if (uplot) uplot.destroy(); | ||||
|       render(data); | ||||
|       render(roofData, jobsData, nodesData); | ||||
|     }, 200); | ||||
|   } | ||||
|  | ||||
|   function render(plotData) { | ||||
|     if (plotData) { | ||||
|   function render(roofData, jobsData, nodesData) { | ||||
|     let plotTitle = "CPU Roofline Diagram"; | ||||
|     if (jobsData) plotTitle = "Job Average Roofline Diagram"; | ||||
|     if (nodesData) plotTitle = "Node Average Roofline Diagram"; | ||||
|  | ||||
|     if (roofData) { | ||||
|       const opts = { | ||||
|         title: "CPU Roofline Diagram", | ||||
|         title: plotTitle, | ||||
|         mode: 2, | ||||
|         width: width, | ||||
|         height: height, | ||||
|         legend: { | ||||
|           show: false, | ||||
|           show: true, | ||||
|         }, | ||||
|         cursor: {  | ||||
|           dataIdx: (u, seriesIdx) => { | ||||
|             if (seriesIdx == 1) { | ||||
|               hRect = null; | ||||
|  | ||||
|               let dist = Infinity; | ||||
|               let area = Infinity; | ||||
|               let cx = u.cursor.left * pxRatio; | ||||
|               let cy = u.cursor.top * pxRatio; | ||||
|  | ||||
|               qt.get(cx, cy, 1, 1, o => { | ||||
|                 if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { | ||||
|                   let ocx = o.x + o.w / 2; | ||||
|                   let ocy = o.y + o.h / 2; | ||||
|  | ||||
|                   let dx = ocx - cx; | ||||
|                   let dy = ocy - cy; | ||||
|  | ||||
|                   let d = Math.sqrt(dx ** 2 + dy ** 2); | ||||
|  | ||||
|                   // test against radius for actual hover | ||||
|                   if (d <= o.w / 2) { | ||||
|                     let a = o.w * o.h; | ||||
|  | ||||
|                     // prefer smallest | ||||
|                     if (a < area) { | ||||
|                       area = a; | ||||
|                       dist = d; | ||||
|                       hRect = o; | ||||
|                     } | ||||
|                     // only hover bbox with closest distance | ||||
|                     else if (a == area && d <= dist) { | ||||
|                       dist = d; | ||||
|                       hRect = o; | ||||
|                     } | ||||
|                   } | ||||
|                 } | ||||
|               }); | ||||
|             } | ||||
|             return hRect && seriesIdx == hRect.sidx ? hRect.didx : null; | ||||
|           }, | ||||
|           /* Render "Fill" on Data Point Hover: Works in Example Bubble, does not work here? Guess: Interference with tooltip */ | ||||
|           // points: { | ||||
|           //   size: (u, seriesIdx) => { | ||||
|           //     return hRect && seriesIdx == hRect.sidx ? hRect.w / pxRatio : 0; | ||||
|           //   } | ||||
|           // }, | ||||
|           /* Make all non-focused series semi-transparent: Useless unless more than one series rendered */ | ||||
|           // focus: { | ||||
|           //   prox: 1e3, | ||||
|           //   alpha: 0.3, | ||||
|           //   dist: (u, seriesIdx) => { | ||||
|           //     let prox = (hRect?.sidx === seriesIdx ? 0 : Infinity); | ||||
|           //     return prox; | ||||
|           //   }, | ||||
|           // }, | ||||
|           drag: { // Activates Zoom: Only one Dimension; YX Breaks Zoom Reset (Reason TBD) | ||||
|             x: true, | ||||
|             y: false | ||||
|           }, | ||||
|         }, | ||||
|         cursor: { drag: { x: false, y: false } }, | ||||
|         axes: [ | ||||
|           { | ||||
|             label: "Intensity [FLOPS/Byte]", | ||||
| @@ -228,7 +616,7 @@ | ||||
|           }, | ||||
|           y: { | ||||
|             range: [ | ||||
|               1.0, | ||||
|               0.01, | ||||
|               subCluster?.flopRateSimd?.value | ||||
|                 ? nearestThousand(subCluster.flopRateSimd.value) | ||||
|                 : 10000, | ||||
| @@ -237,12 +625,36 @@ | ||||
|             log: 10, // log exp | ||||
|           }, | ||||
|         }, | ||||
|         series: [{}, { paths: renderTime ? drawColorPoints : drawPoints }], | ||||
|         series: [ | ||||
|           null, | ||||
|           { | ||||
|             /* Facets: Define Purpose of Sub-Arrays in Series-Array, e.g. x, y, size, label, color, ... */ | ||||
|             // facets: [ | ||||
|             //   { | ||||
|             //     scale: 'x', | ||||
|             //     auto: true, | ||||
|             //   }, | ||||
|             //   { | ||||
|             //     scale: 'y', | ||||
|             //     auto: true, | ||||
|             //   } | ||||
|             // ], | ||||
|             paths: drawPoints, | ||||
|             values: legendValues | ||||
|           } | ||||
|         ], | ||||
|         hooks: { | ||||
|           // setSeries: [ (u, seriesIdx) => console.log('setSeries', seriesIdx) ], | ||||
|           // setLegend: [ u => console.log('setLegend', u.legend.idxs) ], | ||||
|           drawClear: [ | ||||
|             (u) => { | ||||
|               qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); | ||||
|               qt.clear(); | ||||
|  | ||||
|               // force-clear the path cache to cause drawBars() to rebuild new quadtree | ||||
|               u.series.forEach((s, i) => { | ||||
|                 if (i > 0) s._paths = null; | ||||
|                 if (i > 0)  | ||||
|                   s._paths = null; | ||||
|               }); | ||||
|             }, | ||||
|           ], | ||||
| @@ -334,30 +746,92 @@ | ||||
|                 // Reset grid lineWidth | ||||
|                 u.ctx.lineWidth = 0.15; | ||||
|               } | ||||
|               if (renderTime) { | ||||
|                 // The Color Scale For Time Information | ||||
|                 const posX = u.valToPos(0.1, "x", true) | ||||
|                 const posXLimit = u.valToPos(100, "x", true) | ||||
|                 const posY = u.valToPos(14000.0, "y", true) | ||||
|                 u.ctx.fillStyle = 'black' | ||||
|                 u.ctx.fillText('Start', posX, posY) | ||||
|                 const start = posX + 10 | ||||
|                 for (let x = start; x < posXLimit; x += 10) { | ||||
|  | ||||
|               /* Render Scales */ | ||||
|               if (useColors) { | ||||
|                 // Jobs: The Color Scale For Time Information | ||||
|                 if (jobsData) { | ||||
|                   const posX = u.valToPos(0.1, "x", true) | ||||
|                   const posXLimit = u.valToPos(100, "x", true) | ||||
|                   const posY = u.valToPos(17500.0, "y", true) | ||||
|                   u.ctx.fillStyle = 'black' | ||||
|                   u.ctx.fillText('0 Hours', posX, posY) | ||||
|                   const start = posX + 10 | ||||
|                   for (let x = start; x < posXLimit; x += 10) { | ||||
|                     let c = (x - start) / (posXLimit - start) | ||||
|                     u.ctx.fillStyle = getRGB(c) | ||||
|                     u.ctx.beginPath() | ||||
|                     u.ctx.arc(x, posY, 3, 0, Math.PI * 2, false) | ||||
|                     u.ctx.fill() | ||||
|                   } | ||||
|                   u.ctx.fillStyle = 'black' | ||||
|                   u.ctx.fillText('24 Hours', posXLimit + 55, posY) | ||||
|                 } | ||||
|  | ||||
|                 // Nodes: The Colors Of NodeStates | ||||
|                 if (nodesData) { | ||||
|                   const posY = u.valToPos(17500.0, "y", true) | ||||
|  | ||||
|                   const posAllocDot = u.valToPos(0.03, "x", true) | ||||
|                   const posAllocText = posAllocDot + 60 | ||||
|                   const posIdleDot = u.valToPos(0.3, "x", true) | ||||
|                   const posIdleText = posIdleDot + 30 | ||||
|                   const posOtherDot = u.valToPos(3, "x", true) | ||||
|                   const posOtherText = posOtherDot + 40 | ||||
|                   const posMissingDot = u.valToPos(30, "x", true) | ||||
|                   const posMissingText = posMissingDot + 80 | ||||
|  | ||||
|                   u.ctx.fillStyle = "rgb(0, 255, 0)" | ||||
|                   u.ctx.beginPath() | ||||
|                   u.ctx.arc(posAllocDot, posY, 3, 0, Math.PI * 2, false) | ||||
|                   u.ctx.fill() | ||||
|                   u.ctx.fillStyle = 'black' | ||||
|                   u.ctx.fillText('Allocated', posAllocText, posY) | ||||
|  | ||||
|                   u.ctx.fillStyle = "rgb(0, 0, 255)" | ||||
|                   u.ctx.beginPath() | ||||
|                   u.ctx.arc(posIdleDot, posY, 3, 0, Math.PI * 2, false) | ||||
|                   u.ctx.fill() | ||||
|                   u.ctx.fillStyle = 'black' | ||||
|                   u.ctx.fillText('Idle', posIdleText, posY) | ||||
|  | ||||
|                   u.ctx.fillStyle = "rgb(255, 0, 0)" | ||||
|                   u.ctx.beginPath() | ||||
|                   u.ctx.arc(posOtherDot, posY, 3, 0, Math.PI * 2, false) | ||||
|                   u.ctx.fill() | ||||
|                   u.ctx.fillStyle = 'black' | ||||
|                   u.ctx.fillText('Other', posOtherText, posY) | ||||
|  | ||||
|                   u.ctx.fillStyle = 'black' | ||||
|                   u.ctx.beginPath() | ||||
|                   u.ctx.arc(posMissingDot, posY, 3, 0, Math.PI * 2, false) | ||||
|                   u.ctx.fill() | ||||
|                   u.ctx.fillText('Missing in DB', posMissingText, posY) | ||||
|                 } | ||||
|                 u.ctx.fillStyle = 'black' | ||||
|                 u.ctx.fillText('End', posXLimit + 23, posY) | ||||
|               } | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|         // cursor: { drag: { x: true, y: true } } // Activate zoom | ||||
|         plugins: [ | ||||
|           tooltipPlugin({ | ||||
|             onclick(u, dataIdx) { | ||||
|               if (jobsData) { | ||||
|                 window.open(`/monitoring/job/${jobsData[dataIdx].id}`) | ||||
|               } else if (nodesData) { | ||||
|                 window.open(`/monitoring/node/${cluster}/${nodesData[dataIdx].nodeName}`) | ||||
|               } | ||||
|             }, | ||||
|             getLegendData: (u, dataIdx) => { | ||||
|               if (jobsData) { | ||||
|                 return jobsData[dataIdx] | ||||
|               } else if (nodesData) { | ||||
|                 return nodesData[dataIdx] | ||||
|               } | ||||
|             } | ||||
|           }), | ||||
|         ], | ||||
|       }; | ||||
|       uplot = new uPlot(opts, plotData, plotWrapper); | ||||
|       uplot = new uPlot(opts, roofData, plotWrapper); | ||||
|     } else { | ||||
|       // console.log("No data for roofline!"); | ||||
|     } | ||||
| @@ -365,7 +839,7 @@ | ||||
|  | ||||
|   /* On Mount */ | ||||
|   onMount(() => { | ||||
|     render(data); | ||||
|     render(roofData, jobsData, nodesData); | ||||
|   }); | ||||
|  | ||||
|   /* On Destroy */ | ||||
| @@ -375,10 +849,8 @@ | ||||
|   }); | ||||
| </script> | ||||
|  | ||||
| {#if data != null} | ||||
| {#if roofData != null} | ||||
|   <div bind:this={plotWrapper} class="p-2"></div> | ||||
| {:else} | ||||
|   <Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card | ||||
|   > | ||||
|   <Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card> | ||||
| {/if} | ||||
|  | ||||
|   | ||||
							
								
								
									
										384
									
								
								web/frontend/src/generic/plots/RooflineLegacy.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										384
									
								
								web/frontend/src/generic/plots/RooflineLegacy.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,384 @@ | ||||
| <!-- | ||||
|   @component Roofline Model Plot based on uPlot | ||||
|  | ||||
|   Properties: | ||||
|   - `data [null, [], []]`: Roofline Data Structure, see below for details [Default: null] | ||||
|   - `renderTime Bool?`: If time information should be rendered as colored dots [Default: false] | ||||
|   - `allowSizeChange Bool?`: If dimensions of rendered plot can change [Default: false] | ||||
|   - `subCluster GraphQL.SubCluster?`: SubCluster Object; contains required topology information [Default: null] | ||||
|   - `width Number?`: Plot width (reactively adaptive) [Default: 600] | ||||
|   - `height Number?`: Plot height (reactively adaptive) [Default: 380] | ||||
|  | ||||
|   Data Format: | ||||
|   - `data = [null, [], []]`  | ||||
|     - Index 0: null-axis required for scatter | ||||
|     - Index 1: Array of XY-Arrays for Scatter | ||||
|     - Index 2: Optional Time Info | ||||
|   - `data[1][0] = [100, 200, 500, ...]` | ||||
|     - X Axis: Intensity (Vals up to clusters' flopRateScalar value) | ||||
|   - `data[1][1] = [1000, 2000, 1500, ...]` | ||||
|     - Y Axis: Performance (Vals up to clusters' flopRateSimd value) | ||||
|   - `data[2] = [0.1, 0.15, 0.2, ...]` | ||||
|     - Color Code: Time Information (Floats from 0 to 1) (Optional) | ||||
| --> | ||||
|  | ||||
| <script> | ||||
|   import uPlot from "uplot"; | ||||
|   import { formatNumber } from "../units.js"; | ||||
|   import { onMount, onDestroy } from "svelte"; | ||||
|   import { Card } from "@sveltestrap/sveltestrap"; | ||||
|  | ||||
|   /* Svelte 5 Props */ | ||||
|   let { | ||||
|     data = null, | ||||
|     renderTime = false, | ||||
|     allowSizeChange = false, | ||||
|     subCluster = null, | ||||
|     width = 600, | ||||
|     height = 380, | ||||
|   } = $props(); | ||||
|  | ||||
|   /* Const Init */ | ||||
|   const lineWidth = clusterCockpitConfig?.plot_general_lineWidth || 2; | ||||
|   const cbmode = clusterCockpitConfig?.plot_general_colorblindMode || false; | ||||
|  | ||||
|   /* Var Init */ | ||||
|   let timeoutId = null; | ||||
|  | ||||
|   /* State Init */ | ||||
|   let plotWrapper = $state(null); | ||||
|   let uplot = $state(null); | ||||
|  | ||||
|   /* Effect */ | ||||
|   $effect(() => { | ||||
|     if (allowSizeChange) sizeChanged(width, height); | ||||
|   }); | ||||
|  | ||||
|   /* Functions */ | ||||
|   // Helper | ||||
|   function getGradientR(x) { | ||||
|     if (x < 0.5) return 0; | ||||
|     if (x > 0.75) return 255; | ||||
|     x = (x - 0.5) * 4.0; | ||||
|     return Math.floor(x * 255.0); | ||||
|   } | ||||
|   function getGradientG(x) { | ||||
|     if (x > 0.25 && x < 0.75) return 255; | ||||
|     if (x < 0.25) x = x * 4.0; | ||||
|     else x = 1.0 - (x - 0.75) * 4.0; | ||||
|     return Math.floor(x * 255.0); | ||||
|   } | ||||
|   function getGradientB(x) { | ||||
|     if (x < 0.25) return 255; | ||||
|     if (x > 0.5) return 0; | ||||
|     x = 1.0 - (x - 0.25) * 4.0; | ||||
|     return Math.floor(x * 255.0); | ||||
|   } | ||||
|   function getRGB(c) { | ||||
|     return `rgb(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)})`; | ||||
|   } | ||||
|   function nearestThousand(num) { | ||||
|     return Math.ceil(num / 1000) * 1000; | ||||
|   } | ||||
|   function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) { | ||||
|     let l = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1); | ||||
|     let a = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / l; | ||||
|     return { | ||||
|       x: x1 + a * (x2 - x1), | ||||
|       y: y1 + a * (y2 - y1), | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   // Dot Renderers | ||||
|   const drawColorPoints = (u, seriesIdx, idx0, idx1) => { | ||||
|     const size = 5 * devicePixelRatio; | ||||
|     uPlot.orient( | ||||
|       u, | ||||
|       seriesIdx, | ||||
|       ( | ||||
|         series, | ||||
|         dataX, | ||||
|         dataY, | ||||
|         scaleX, | ||||
|         scaleY, | ||||
|         valToPosX, | ||||
|         valToPosY, | ||||
|         xOff, | ||||
|         yOff, | ||||
|         xDim, | ||||
|         yDim, | ||||
|         moveTo, | ||||
|         lineTo, | ||||
|         rect, | ||||
|         arc, | ||||
|       ) => { | ||||
|         let d = u.data[seriesIdx]; | ||||
|         let deg360 = 2 * Math.PI; | ||||
|         for (let i = 0; i < d[0].length; i++) { | ||||
|           let p = new Path2D(); | ||||
|           let xVal = d[0][i]; | ||||
|           let yVal = d[1][i]; | ||||
|           u.ctx.strokeStyle = getRGB(u.data[2][i]); | ||||
|           u.ctx.fillStyle = getRGB(u.data[2][i]); | ||||
|           if ( | ||||
|             xVal >= scaleX.min && | ||||
|             xVal <= scaleX.max && | ||||
|             yVal >= scaleY.min && | ||||
|             yVal <= scaleY.max | ||||
|           ) { | ||||
|             let cx = valToPosX(xVal, scaleX, xDim, xOff); | ||||
|             let cy = valToPosY(yVal, scaleY, yDim, yOff); | ||||
|  | ||||
|             p.moveTo(cx + size / 2, cy); | ||||
|             arc(p, cx, cy, size / 2, 0, deg360); | ||||
|           } | ||||
|           u.ctx.fill(p); | ||||
|         } | ||||
|       }, | ||||
|     ); | ||||
|     return null; | ||||
|   }; | ||||
|  | ||||
|   const drawPoints = (u, seriesIdx, idx0, idx1) => { | ||||
|     const size = 5 * devicePixelRatio; | ||||
|     uPlot.orient( | ||||
|       u, | ||||
|       seriesIdx, | ||||
|       ( | ||||
|         series, | ||||
|         dataX, | ||||
|         dataY, | ||||
|         scaleX, | ||||
|         scaleY, | ||||
|         valToPosX, | ||||
|         valToPosY, | ||||
|         xOff, | ||||
|         yOff, | ||||
|         xDim, | ||||
|         yDim, | ||||
|         moveTo, | ||||
|         lineTo, | ||||
|         rect, | ||||
|         arc, | ||||
|       ) => { | ||||
|         let d = u.data[seriesIdx]; | ||||
|         u.ctx.strokeStyle = getRGB(0); | ||||
|         u.ctx.fillStyle = getRGB(0); | ||||
|         let deg360 = 2 * Math.PI; | ||||
|         let p = new Path2D(); | ||||
|         for (let i = 0; i < d[0].length; i++) { | ||||
|           let xVal = d[0][i]; | ||||
|           let yVal = d[1][i]; | ||||
|           if ( | ||||
|             xVal >= scaleX.min && | ||||
|             xVal <= scaleX.max && | ||||
|             yVal >= scaleY.min && | ||||
|             yVal <= scaleY.max | ||||
|           ) { | ||||
|             let cx = valToPosX(xVal, scaleX, xDim, xOff); | ||||
|             let cy = valToPosY(yVal, scaleY, yDim, yOff); | ||||
|             p.moveTo(cx + size / 2, cy); | ||||
|             arc(p, cx, cy, size / 2, 0, deg360); | ||||
|           } | ||||
|         } | ||||
|         u.ctx.fill(p); | ||||
|       }, | ||||
|     ); | ||||
|     return null; | ||||
|   }; | ||||
|  | ||||
|   // Main Functions | ||||
|   function sizeChanged() { | ||||
|     if (timeoutId != null) clearTimeout(timeoutId); | ||||
|  | ||||
|     timeoutId = setTimeout(() => { | ||||
|       timeoutId = null; | ||||
|       if (uplot) uplot.destroy(); | ||||
|       render(data); | ||||
|     }, 200); | ||||
|   } | ||||
|  | ||||
|   function render(plotData) { | ||||
|     if (plotData) { | ||||
|       const opts = { | ||||
|         title: "CPU Roofline Diagram", | ||||
|         mode: 2, | ||||
|         width: width, | ||||
|         height: height, | ||||
|         legend: { | ||||
|           show: false, | ||||
|         }, | ||||
|         cursor: { drag: { x: false, y: false } }, | ||||
|         axes: [ | ||||
|           { | ||||
|             label: "Intensity [FLOPS/Byte]", | ||||
|             values: (u, vals) => vals.map((v) => formatNumber(v)), | ||||
|           }, | ||||
|           { | ||||
|             label: "Performace [GFLOPS]", | ||||
|             values: (u, vals) => vals.map((v) => formatNumber(v)), | ||||
|           }, | ||||
|         ], | ||||
|         scales: { | ||||
|           x: { | ||||
|             time: false, | ||||
|             range: [0.01, 1000], | ||||
|             distr: 3, // Render as log | ||||
|             log: 10, // log exp | ||||
|           }, | ||||
|           y: { | ||||
|             range: [ | ||||
|               1.0, | ||||
|               subCluster?.flopRateSimd?.value | ||||
|                 ? nearestThousand(subCluster.flopRateSimd.value) | ||||
|                 : 10000, | ||||
|             ], | ||||
|             distr: 3, // Render as log | ||||
|             log: 10, // log exp | ||||
|           }, | ||||
|         }, | ||||
|         series: [{}, { paths: renderTime ? drawColorPoints : drawPoints }], | ||||
|         hooks: { | ||||
|           drawClear: [ | ||||
|             (u) => { | ||||
|               u.series.forEach((s, i) => { | ||||
|                 if (i > 0) s._paths = null; | ||||
|               }); | ||||
|             }, | ||||
|           ], | ||||
|           draw: [ | ||||
|             (u) => { | ||||
|               // draw roofs when subCluster set | ||||
|               if (subCluster != null) { | ||||
|                 const padding = u._padding; // [top, right, bottom, left] | ||||
|  | ||||
|                 u.ctx.strokeStyle = "black"; | ||||
|                 u.ctx.lineWidth = lineWidth; | ||||
|                 u.ctx.beginPath(); | ||||
|  | ||||
|                 const ycut = 0.01 * subCluster.memoryBandwidth.value; | ||||
|                 const scalarKnee = | ||||
|                   (subCluster.flopRateScalar.value - ycut) / | ||||
|                   subCluster.memoryBandwidth.value; | ||||
|                 const simdKnee = | ||||
|                   (subCluster.flopRateSimd.value - ycut) / | ||||
|                   subCluster.memoryBandwidth.value; | ||||
|                 const scalarKneeX = u.valToPos(scalarKnee, "x", true), // Value, axis, toCanvasPixels | ||||
|                   simdKneeX = u.valToPos(simdKnee, "x", true), | ||||
|                   flopRateScalarY = u.valToPos( | ||||
|                     subCluster.flopRateScalar.value, | ||||
|                     "y", | ||||
|                     true, | ||||
|                   ), | ||||
|                   flopRateSimdY = u.valToPos( | ||||
|                     subCluster.flopRateSimd.value, | ||||
|                     "y", | ||||
|                     true, | ||||
|                   ); | ||||
|  | ||||
|                 if ( | ||||
|                   scalarKneeX < | ||||
|                   width * window.devicePixelRatio - | ||||
|                     padding[1] * window.devicePixelRatio | ||||
|                 ) { | ||||
|                   // Lower horizontal roofline | ||||
|                   u.ctx.moveTo(scalarKneeX, flopRateScalarY); | ||||
|                   u.ctx.lineTo( | ||||
|                     width * window.devicePixelRatio - | ||||
|                       padding[1] * window.devicePixelRatio, | ||||
|                     flopRateScalarY, | ||||
|                   ); | ||||
|                 } | ||||
|  | ||||
|                 if ( | ||||
|                   simdKneeX < | ||||
|                   width * window.devicePixelRatio - | ||||
|                     padding[1] * window.devicePixelRatio | ||||
|                 ) { | ||||
|                   // Top horitontal roofline | ||||
|                   u.ctx.moveTo(simdKneeX, flopRateSimdY); | ||||
|                   u.ctx.lineTo( | ||||
|                     width * window.devicePixelRatio - | ||||
|                       padding[1] * window.devicePixelRatio, | ||||
|                     flopRateSimdY, | ||||
|                   ); | ||||
|                 } | ||||
|  | ||||
|                 let x1 = u.valToPos(0.01, "x", true), | ||||
|                   y1 = u.valToPos(ycut, "y", true); | ||||
|  | ||||
|                 let x2 = u.valToPos(simdKnee, "x", true), | ||||
|                   y2 = flopRateSimdY; | ||||
|  | ||||
|                 let xAxisIntersect = lineIntersect( | ||||
|                   x1, | ||||
|                   y1, | ||||
|                   x2, | ||||
|                   y2, | ||||
|                   u.valToPos(0.01, "x", true), | ||||
|                   u.valToPos(1.0, "y", true), // X-Axis Start Coords | ||||
|                   u.valToPos(1000, "x", true), | ||||
|                   u.valToPos(1.0, "y", true), // X-Axis End Coords | ||||
|                 ); | ||||
|  | ||||
|                 if (xAxisIntersect.x > x1) { | ||||
|                   x1 = xAxisIntersect.x; | ||||
|                   y1 = xAxisIntersect.y; | ||||
|                 } | ||||
|  | ||||
|                 // Diagonal | ||||
|                 u.ctx.moveTo(x1, y1); | ||||
|                 u.ctx.lineTo(x2, y2); | ||||
|  | ||||
|                 u.ctx.stroke(); | ||||
|                 // Reset grid lineWidth | ||||
|                 u.ctx.lineWidth = 0.15; | ||||
|               } | ||||
|               if (renderTime) { | ||||
|                 // The Color Scale For Time Information | ||||
|                 const posX = u.valToPos(0.1, "x", true) | ||||
|                 const posXLimit = u.valToPos(100, "x", true) | ||||
|                 const posY = u.valToPos(14000.0, "y", true) | ||||
|                 u.ctx.fillStyle = 'black' | ||||
|                 u.ctx.fillText('Start', posX, posY) | ||||
|                 const start = posX + 10 | ||||
|                 for (let x = start; x < posXLimit; x += 10) { | ||||
|                     let c = (x - start) / (posXLimit - start) | ||||
|                     u.ctx.fillStyle = getRGB(c) | ||||
|                     u.ctx.beginPath() | ||||
|                     u.ctx.arc(x, posY, 3, 0, Math.PI * 2, false) | ||||
|                     u.ctx.fill() | ||||
|                 } | ||||
|                 u.ctx.fillStyle = 'black' | ||||
|                 u.ctx.fillText('End', posXLimit + 23, posY) | ||||
|               } | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|         // cursor: { drag: { x: true, y: true } } // Activate zoom | ||||
|       }; | ||||
|       uplot = new uPlot(opts, plotData, plotWrapper); | ||||
|     } else { | ||||
|       // console.log("No data for roofline!"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /* On Mount */ | ||||
|   onMount(() => { | ||||
|     render(data); | ||||
|   }); | ||||
|  | ||||
|   /* On Destroy */ | ||||
|   onDestroy(() => { | ||||
|     if (uplot) uplot.destroy(); | ||||
|     if (timeoutId != null) clearTimeout(timeoutId); | ||||
|   }); | ||||
| </script> | ||||
|  | ||||
| {#if data != null} | ||||
|   <div bind:this={plotWrapper} class="p-2"></div> | ||||
| {:else} | ||||
|   <Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card | ||||
|   > | ||||
| {/if} | ||||
|  | ||||
| @@ -3,8 +3,9 @@ | ||||
|  | ||||
|   Properties: | ||||
|   - `cluster String`: Currently selected cluster | ||||
|   - `selectedHistograms [String]`: The currently selected metrics to display as histogram | ||||
|   - `ìsOpen Bool`: Is selection opened [Bindable] | ||||
|   - `configName String`: The config id string to be updated in database on selection change | ||||
|   - `presetSelectedHistograms [String]`: The currently selected metrics to display as histogram | ||||
|   - `applyChange Func`: The callback function to apply current selection | ||||
| --> | ||||
|  | ||||
| @@ -25,6 +26,7 @@ | ||||
|   let { | ||||
|     cluster, | ||||
|     isOpen = $bindable(), | ||||
|     configName, | ||||
|     presetSelectedHistograms, | ||||
|     applyChange | ||||
|   } = $props(); | ||||
| @@ -67,8 +69,8 @@ | ||||
|     applyChange(selectedHistograms) | ||||
|     updateConfiguration({ | ||||
|       name: cluster | ||||
|         ? `user_view_histogramMetrics:${cluster}` | ||||
|         : "user_view_histogramMetrics", | ||||
|         ? `${configName}:${cluster}` | ||||
|         : configName, | ||||
|       value: selectedHistograms, | ||||
|     }); | ||||
|   } | ||||
|   | ||||
| @@ -96,9 +96,9 @@ | ||||
|   function printAvailability(metric, cluster) { | ||||
|     const avail = globalMetrics.find((gm) => gm.name === metric)?.availability | ||||
|     if (!cluster) { | ||||
|       return avail.map((av) => av.cluster).join(',') | ||||
|       return avail.map((av) => av.cluster).join(', ') | ||||
|     } else { | ||||
|       return avail.find((av) => av.cluster === cluster).subClusters.join(',') | ||||
|       return avail.find((av) => av.cluster === cluster).subClusters.join(', ') | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -208,7 +208,7 @@ | ||||
|             /> | ||||
|           {/if} | ||||
|           {metric} | ||||
|           <span style="float: right;"> | ||||
|           <span style="float: right; text-align: justify;"> | ||||
|             {printAvailability(metric, cluster)} | ||||
|           </span> | ||||
|         </li> | ||||
|   | ||||
| @@ -19,7 +19,7 @@ | ||||
|   import { | ||||
|     transformDataForRoofline, | ||||
|   } from "../generic/utils.js"; | ||||
|   import Roofline from "../generic/plots/Roofline.svelte"; | ||||
|   import Roofline from "../generic/plots/RooflineLegacy.svelte"; | ||||
|  | ||||
|   /* Svelte 5 Props */ | ||||
|   let { | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import Status from './Status.root.svelte' | ||||
| mount(Status, { | ||||
|     target: document.getElementById('svelte-app'), | ||||
|     props: { | ||||
|         cluster: infos.cluster, | ||||
|         presetCluster: infos.cluster, | ||||
|     }, | ||||
|     context: new Map([ | ||||
|             ['cc-config', clusterCockpitConfig] | ||||
|   | ||||
							
								
								
									
										159
									
								
								web/frontend/src/status/StatisticsDash.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								web/frontend/src/status/StatisticsDash.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | ||||
| <!-- | ||||
|   @component Main cluster status view component; renders current system-usage information | ||||
|  | ||||
|   Properties: | ||||
|   - `presetCluster String`: The cluster to show status information for | ||||
| --> | ||||
|  | ||||
|  <script> | ||||
|   import { getContext } from "svelte"; | ||||
|   import { | ||||
|     Row, | ||||
|     Col, | ||||
|     Spinner, | ||||
|     Card, | ||||
|     Icon, | ||||
|     Button, | ||||
|   } from "@sveltestrap/sveltestrap"; | ||||
|   import { | ||||
|     queryStore, | ||||
|     gql, | ||||
|     getContextClient, | ||||
|   } from "@urql/svelte"; | ||||
|   import { | ||||
|     init, | ||||
|     convert2uplot, | ||||
|   } from "../generic/utils.js"; | ||||
|   import PlotGrid from "../generic/PlotGrid.svelte"; | ||||
|   import Histogram from "../generic/plots/Histogram.svelte"; | ||||
|   import HistogramSelection from "../generic/select/HistogramSelection.svelte"; | ||||
|   import Refresher from "../generic/helper/Refresher.svelte"; | ||||
|  | ||||
|   /* Svelte 5 Props */ | ||||
|   let { | ||||
|     presetCluster | ||||
|   } = $props(); | ||||
|  | ||||
|   /* Const Init */ | ||||
|   const { query: initq } = init(); | ||||
|   const ccconfig = getContext("cc-config"); | ||||
|   const client = getContextClient(); | ||||
|  | ||||
|   /* State Init */ | ||||
|   let cluster = $state(presetCluster); | ||||
|   // Histogram | ||||
|   let isHistogramSelectionOpen = $state(false); | ||||
|   let from = $state(new Date(Date.now() - (30 * 24 * 60 * 60 * 1000))); // Simple way to retrigger GQL: Jobs Started last Month | ||||
|   let to = $state(new Date(Date.now())); | ||||
|  | ||||
|   /* Derived */ | ||||
|   let selectedHistograms = $derived(cluster | ||||
|     ? ccconfig[`status_view_selectedHistograms:${cluster}`] || ( ccconfig['status_view_selectedHistograms'] || [] ) | ||||
|     : ccconfig['status_view_selectedHistograms'] || []); | ||||
|  | ||||
|   // Note: nodeMetrics are requested on configured $timestep resolution | ||||
|   const metricStatusQuery = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ( | ||||
|         $filter: [JobFilter!]! | ||||
|         $selectedHistograms: [String!] | ||||
|       ) { | ||||
|         jobsStatistics(filter: $filter, metrics: $selectedHistograms) { | ||||
|           histMetrics { | ||||
|             metric | ||||
|             unit | ||||
|             data { | ||||
|               min | ||||
|               max | ||||
|               count | ||||
|               bin | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       filter: [{ state: ["running"] }, { cluster: { eq: cluster}}, {startTime: { from, to }}], | ||||
|       selectedHistograms: selectedHistograms, | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <!-- Loading indicators & Metric Sleect --> | ||||
| <Row class="justify-content-between"> | ||||
|   <Col class="mb-2 mb-md-0" xs="12" md="5" lg="4" xl="3"> | ||||
|     <Button | ||||
|       outline | ||||
|       color="secondary" | ||||
|       onclick={() => (isHistogramSelectionOpen = true)} | ||||
|     > | ||||
|       <Icon name="bar-chart-line" /> Select Histograms | ||||
|     </Button> | ||||
|   </Col> | ||||
|   <Col xs="12" md="5" lg="4" xl="3"> | ||||
|     <Refresher | ||||
|       initially={120} | ||||
|       onRefresh={() => { | ||||
|         from = new Date(Date.now() - (30 * 24 * 60 * 60 * 1000)); // Triggers GQL | ||||
|         to = new Date(Date.now()); | ||||
|       }} | ||||
|     /> | ||||
|   </Col> | ||||
| </Row> | ||||
|  | ||||
| <Row cols={1} class="text-center mt-3"> | ||||
|   <Col> | ||||
|     {#if $initq.fetching || $metricStatusQuery.fetching} | ||||
|       <Spinner /> | ||||
|     {:else if $initq.error} | ||||
|       <Card body color="danger">{$initq.error.message}</Card> | ||||
|     {:else} | ||||
|       <!-- ... --> | ||||
|     {/if} | ||||
|   </Col> | ||||
| </Row> | ||||
| {#if $metricStatusQuery.error} | ||||
|   <Row cols={1}> | ||||
|     <Col> | ||||
|       <Card body color="danger">{$metricStatusQuery.error.message}</Card> | ||||
|     </Col> | ||||
|   </Row> | ||||
| {/if} | ||||
|  | ||||
| {#if $initq.data && $metricStatusQuery.data} | ||||
|   <!-- Selectable Stats as Histograms : Average Values of Running Jobs --> | ||||
|   {#if selectedHistograms} | ||||
|     <!-- Note: Ignore '#snippet' Error in IDE --> | ||||
|     {#snippet gridContent(item)} | ||||
|       <Histogram | ||||
|         data={convert2uplot(item.data)} | ||||
|         title="Distribution of '{item.metric}' averages" | ||||
|         xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`} | ||||
|         xunit={item.unit} | ||||
|         ylabel="Number of Jobs" | ||||
|         yunit="Jobs" | ||||
|         usesBins | ||||
|       /> | ||||
|     {/snippet} | ||||
|      | ||||
|     {#key $metricStatusQuery.data.jobsStatistics[0].histMetrics} | ||||
|       <PlotGrid | ||||
|         items={$metricStatusQuery.data.jobsStatistics[0].histMetrics} | ||||
|         itemsPerRow={2} | ||||
|         {gridContent} | ||||
|       /> | ||||
|     {/key} | ||||
|   {/if} | ||||
| {/if} | ||||
|  | ||||
| <HistogramSelection | ||||
|   {cluster} | ||||
|   bind:isOpen={isHistogramSelectionOpen} | ||||
|   presetSelectedHistograms={selectedHistograms} | ||||
|   configName="status_view_selectedHistograms" | ||||
|   applyChange={(newSelection) => { | ||||
|     selectedHistograms = [...newSelection]; | ||||
|   }} | ||||
| /> | ||||
							
								
								
									
										580
									
								
								web/frontend/src/status/StatusDash.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										580
									
								
								web/frontend/src/status/StatusDash.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,580 @@ | ||||
| <!-- | ||||
|   @component Main cluster status view component; renders current system-usage information | ||||
|  | ||||
|   Properties: | ||||
|   - `presetCluster String`: The cluster to show status information for | ||||
| --> | ||||
|  | ||||
|  <script> | ||||
|   import { | ||||
|     Row, | ||||
|     Col, | ||||
|     Card, | ||||
|     CardHeader, | ||||
|     CardTitle, | ||||
|     CardBody, | ||||
|     Table, | ||||
|     Progress, | ||||
|     Icon, | ||||
|   } from "@sveltestrap/sveltestrap"; | ||||
|   import { | ||||
|     queryStore, | ||||
|     gql, | ||||
|     getContextClient, | ||||
|   } from "@urql/svelte"; | ||||
|   import { | ||||
|     init, | ||||
|   } from "../generic/utils.js"; | ||||
|   import { scaleNumbers, formatTime } from "../generic/units.js"; | ||||
|   import Refresher from "../generic/helper/Refresher.svelte"; | ||||
|   import Roofline from "../generic/plots/Roofline.svelte"; | ||||
|   import Pie, { colors } from "../generic/plots/Pie.svelte"; | ||||
|  | ||||
|   /* Svelte 5 Props */ | ||||
|   let { | ||||
|     presetCluster, | ||||
|     useCbColors = false, | ||||
|     useAltColors = false, | ||||
|   } = $props(); | ||||
|  | ||||
|   /* Const Init */ | ||||
|   const { query: initq } = init(); | ||||
|   const client = getContextClient(); | ||||
|  | ||||
|   /* State Init */ | ||||
|   let cluster = $state(presetCluster); | ||||
|   let pieWidth = $state(0); | ||||
|   let plotWidths = $state([]); | ||||
|   let from = $state(new Date(Date.now() - 5 * 60 * 1000)); | ||||
|   let to = $state(new Date(Date.now())); | ||||
|   // Bar Gauges | ||||
|   let allocatedNodes = $state({}); | ||||
|   let allocatedAccs = $state({}); | ||||
|   let flopRate = $state({}); | ||||
|   let flopRateUnitPrefix = $state({}); | ||||
|   let flopRateUnitBase = $state({}); | ||||
|   let memBwRate = $state({}); | ||||
|   let memBwRateUnitPrefix = $state({}); | ||||
|   let memBwRateUnitBase = $state({}); | ||||
|   // Plain Infos | ||||
|   let runningJobs = $state({}); | ||||
|   let activeUsers = $state({}); | ||||
|   let totalAccs = $state({}); | ||||
|  | ||||
|   /* Derived */ | ||||
|   // Accumulated NodeStates for Piecharts | ||||
|   const nodesStateCounts = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ($filter: [NodeFilter!]) { | ||||
|         nodeStates(filter: $filter) { | ||||
|           state | ||||
|           count | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       filter: { cluster: { eq: cluster }} | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
|   const refinedStateData = $derived.by(() => { | ||||
|     return $nodesStateCounts?.data?.nodeStates.filter((e) => ['allocated', 'reserved', 'idle', 'mixed','down', 'unknown'].includes(e.state)) | ||||
|   }); | ||||
|  | ||||
|   const refinedHealthData = $derived.by(() => { | ||||
|     return $nodesStateCounts?.data?.nodeStates.filter((e) => ['full', 'partial', 'failed'].includes(e.state)) | ||||
|   }); | ||||
|  | ||||
|   // Note: nodeMetrics are requested on configured $timestep resolution | ||||
|   // Result: The latest 5 minutes (datapoints) for each node independent of job | ||||
|   const statusQuery = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ( | ||||
|         $cluster: String! | ||||
|         $metrics: [String!] | ||||
|         $from: Time! | ||||
|         $to: Time! | ||||
|         $jobFilter: [JobFilter!]! | ||||
|         $nodeFilter: [NodeFilter!]! | ||||
|         $paging: PageRequest! | ||||
|         $sorting: OrderByInput! | ||||
|       ) { | ||||
|         # Node 5 Minute Averages for Roofline | ||||
|         nodeMetrics( | ||||
|           cluster: $cluster | ||||
|           metrics: $metrics | ||||
|           from: $from | ||||
|           to: $to | ||||
|         ) { | ||||
|           host | ||||
|           subCluster | ||||
|           metrics { | ||||
|             name | ||||
|             metric { | ||||
|               series { | ||||
|                 statistics { | ||||
|                   avg | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|         # Running Job Metric Average for Rooflines | ||||
|         jobsMetricStats(filter: $jobFilter, metrics: $metrics) { | ||||
|           id | ||||
|           jobId | ||||
|           duration | ||||
|           numNodes | ||||
|           numAccelerators | ||||
|           subCluster | ||||
|           stats { | ||||
|             name | ||||
|             data { | ||||
|               avg | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|         # Get Jobs for Per-Node Counts | ||||
|         jobs(filter: $jobFilter, order: $sorting, page: $paging) { | ||||
|           items { | ||||
|             jobId | ||||
|             resources { | ||||
|               hostname | ||||
|             } | ||||
|           } | ||||
|           count | ||||
|         } | ||||
|         # Only counts shared nodes once  | ||||
|         allocatedNodes(cluster: $cluster) { | ||||
|           name | ||||
|           count | ||||
|         } | ||||
|         # Get States for Node Roofline; $sorting unused in backend: Use placeholder | ||||
|         nodes(filter: $nodeFilter, order: $sorting) { | ||||
|           count | ||||
|           items { | ||||
|             hostname | ||||
|             cluster | ||||
|             subCluster | ||||
|             nodeState | ||||
|           } | ||||
|         } | ||||
|         # totalNodes includes multiples if shared jobs | ||||
|         jobsStatistics( | ||||
|           filter: $jobFilter | ||||
|           page: $paging | ||||
|           sortBy: TOTALJOBS | ||||
|           groupBy: SUBCLUSTER | ||||
|         ) { | ||||
|           id | ||||
|           totalJobs | ||||
|           totalUsers | ||||
|           totalAccs | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       cluster: cluster, | ||||
|       metrics: ["flops_any", "mem_bw"], // Fixed names for roofline and status bars | ||||
|       from: from.toISOString(), | ||||
|       to: to.toISOString(), | ||||
|       jobFilter: [{ state: ["running"] }, { cluster: { eq: cluster } }], | ||||
|       nodeFilter: { cluster: { eq: cluster }}, | ||||
|       paging: { itemsPerPage: -1, page: 1 }, // Get all: -1 | ||||
|       sorting: { field: "startTime", type: "col", order: "DESC" } | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
|   /* Effects */ | ||||
|   $effect(() => { | ||||
|     if ($initq.data && $statusQuery.data) { | ||||
|       let subClusters = $initq.data.clusters.find( | ||||
|         (c) => c.name == cluster, | ||||
|       ).subClusters; | ||||
|       for (let subCluster of subClusters) { | ||||
|         // Allocations | ||||
|         allocatedNodes[subCluster.name] = | ||||
|           $statusQuery.data.allocatedNodes.find( | ||||
|             ({ name }) => name == subCluster.name, | ||||
|           )?.count || 0; | ||||
|         allocatedAccs[subCluster.name] = | ||||
|           $statusQuery.data.jobsStatistics.find( | ||||
|             ({ id }) => id == subCluster.name, | ||||
|           )?.totalAccs || 0; | ||||
|         // Infos | ||||
|         activeUsers[subCluster.name] = | ||||
|           $statusQuery.data.jobsStatistics.find( | ||||
|             ({ id }) => id == subCluster.name, | ||||
|           )?.totalUsers || 0; | ||||
|         runningJobs[subCluster.name] = | ||||
|           $statusQuery.data.jobsStatistics.find( | ||||
|             ({ id }) => id == subCluster.name, | ||||
|           )?.totalJobs || 0; | ||||
|         totalAccs[subCluster.name] = | ||||
|           (subCluster?.numberOfNodes * subCluster?.topology?.accelerators?.length) || null; | ||||
|         // Keymetrics | ||||
|         flopRate[subCluster.name] = | ||||
|           Math.floor( | ||||
|             sumUp($statusQuery.data.nodeMetrics, subCluster.name, "flops_any") * | ||||
|               100, | ||||
|           ) / 100; | ||||
|         flopRateUnitPrefix[subCluster.name] = subCluster.flopRateSimd.unit.prefix; | ||||
|         flopRateUnitBase[subCluster.name] = subCluster.flopRateSimd.unit.base; | ||||
|         memBwRate[subCluster.name] = | ||||
|           Math.floor( | ||||
|             sumUp($statusQuery.data.nodeMetrics, subCluster.name, "mem_bw") * 100, | ||||
|           ) / 100; | ||||
|         memBwRateUnitPrefix[subCluster.name] = | ||||
|           subCluster.memoryBandwidth.unit.prefix; | ||||
|         memBwRateUnitBase[subCluster.name] = subCluster.memoryBandwidth.unit.base; | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   /* Const Functions */ | ||||
|   const sumUp = (data, subcluster, metric) => | ||||
|     data.reduce( | ||||
|       (sum, node) => | ||||
|         node.subCluster == subcluster | ||||
|           ? sum + | ||||
|             (node.metrics | ||||
|               .find((m) => m.name == metric) | ||||
|               ?.metric?.series[0]?.statistics?.avg || 0 | ||||
|             ) | ||||
|           : sum, | ||||
|       0, | ||||
|     ); | ||||
|  | ||||
|   /* Functions */ | ||||
|   function transformJobsStatsToData(subclusterData) { | ||||
|     /* c will contain values from 0 to 1 representing the duration */ | ||||
|     let data = null | ||||
|     const x = [], y = [], c = [], day = 86400.0 | ||||
|  | ||||
|     if (subclusterData) { | ||||
|       for (let i = 0; i < subclusterData.length; i++) { | ||||
|         const flopsData = subclusterData[i].stats.find((s) => s.name == "flops_any") | ||||
|         const memBwData = subclusterData[i].stats.find((s) => s.name == "mem_bw") | ||||
|              | ||||
|         const f = flopsData.data.avg | ||||
|         const m = memBwData.data.avg | ||||
|         const d = subclusterData[i].duration / day | ||||
|  | ||||
|         const intensity = f / m | ||||
|         if (Number.isNaN(intensity) || !Number.isFinite(intensity)) | ||||
|             continue | ||||
|  | ||||
|         x.push(intensity) | ||||
|         y.push(f) | ||||
|         // Long Jobs > 1 Day: Use max Color | ||||
|         if (d > 1.0) c.push(1.0) | ||||
|         else c.push(d) | ||||
|       } | ||||
|     } else { | ||||
|         console.warn("transformJobsStatsToData: metrics for 'mem_bw' and/or 'flops_any' missing!") | ||||
|     } | ||||
|  | ||||
|     if (x.length > 0 && y.length > 0 && c.length > 0) { | ||||
|         data = [null, [x, y], c] // for dataformat see roofline.svelte | ||||
|     } | ||||
|     return data | ||||
|   } | ||||
|  | ||||
|   function transformNodesStatsToData(subclusterData) { | ||||
|     let data = null | ||||
|     const x = [], y = [] | ||||
|  | ||||
|     if (subclusterData) { | ||||
|       for (let i = 0; i < subclusterData.length; i++) { | ||||
|         const flopsData = subclusterData[i].metrics.find((s) => s.name == "flops_any") | ||||
|         const memBwData = subclusterData[i].metrics.find((s) => s.name == "mem_bw") | ||||
|  | ||||
|         const f = flopsData.metric.series[0].statistics.avg | ||||
|         const m = memBwData.metric.series[0].statistics.avg | ||||
|  | ||||
|         let intensity = f / m | ||||
|         if (Number.isNaN(intensity) || !Number.isFinite(intensity)) { | ||||
|             intensity = 0.0 // Set to Float Zero: Will not show in Log-Plot (Always below render limit) | ||||
|         } | ||||
|  | ||||
|         x.push(intensity) | ||||
|         y.push(f) | ||||
|       } | ||||
|     } else { | ||||
|         // console.warn("transformNodesStatsToData: metrics for 'mem_bw' and/or 'flops_any' missing!") | ||||
|     } | ||||
|  | ||||
|     if (x.length > 0 && y.length > 0) { | ||||
|         data = [null, [x, y]] // for dataformat see roofline.svelte | ||||
|     } | ||||
|     return data | ||||
|   } | ||||
|  | ||||
|   function transformJobsStatsToInfo(subclusterData) { | ||||
|     if (subclusterData) { | ||||
|         return subclusterData.map((sc) => { return {id: sc.id, jobId: sc.jobId, numNodes: sc.numNodes, numAcc: sc?.numAccelerators? sc.numAccelerators : 0, duration: formatTime(sc.duration)} }) | ||||
|     } else { | ||||
|         console.warn("transformJobsStatsToInfo: jobInfo missing!") | ||||
|         return [] | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function transformNodesStatsToInfo(subClusterData) { | ||||
|     let result = []; | ||||
|     if (subClusterData) { //  && $nodesState?.data) { | ||||
|       // Use Nodes as Returned from CCMS, *NOT* as saved in DB via SlurmState-API! | ||||
|       for (let j = 0; j < subClusterData.length; j++) { | ||||
|         const nodeName = subClusterData[j]?.host ? subClusterData[j].host : "unknown" | ||||
|         const nodeMatch = $statusQuery?.data?.nodes?.items?.find((n) => n.hostname == nodeName && n.subCluster == subClusterData[j].subCluster); | ||||
|         const nodeState = nodeMatch?.nodeState ? nodeMatch.nodeState : "notindb" | ||||
|         let numJobs = 0 | ||||
|  | ||||
|         if ($statusQuery?.data) { | ||||
|           const nodeJobs = $statusQuery?.data?.jobs?.items?.filter((job) => job.resources.find((res) => res.hostname == nodeName)) | ||||
|           numJobs = nodeJobs?.length ? nodeJobs.length : 0 | ||||
|         } | ||||
|  | ||||
|         result.push({nodeName: nodeName, nodeState: nodeState, numJobs: numJobs}) | ||||
|       }; | ||||
|     }; | ||||
|     return result | ||||
|   } | ||||
|  | ||||
|   function legendColors(targetIdx) { | ||||
|     // Reuses first color if targetIdx overflows | ||||
|     let c; | ||||
|       if (useCbColors) { | ||||
|         c = [...colors['colorblind']]; | ||||
|       } else if (useAltColors) { | ||||
|         c = [...colors['alternative']]; | ||||
|       } else { | ||||
|         c = [...colors['default']]; | ||||
|       } | ||||
|     return  c[(c.length + targetIdx) % c.length]; | ||||
|   } | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <!-- Refresher and space for other options --> | ||||
| <Row class="justify-content-end"> | ||||
|   <Col xs="12" md="5" lg="4" xl="3"> | ||||
|     <Refresher | ||||
|       initially={120} | ||||
|       onRefresh={() => { | ||||
|         from = new Date(Date.now() - 5 * 60 * 1000); | ||||
|         to = new Date(Date.now()); | ||||
|       }} | ||||
|     /> | ||||
|   </Col> | ||||
| </Row> | ||||
|  | ||||
| <hr/> | ||||
|  | ||||
| <!-- Node Health Pis, later Charts --> | ||||
| {#if $initq.data && $nodesStateCounts.data} | ||||
|   <Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center"> | ||||
|     <Col class="px-3 mt-2 mt-lg-0"> | ||||
|       <div bind:clientWidth={pieWidth}> | ||||
|         {#key refinedStateData} | ||||
|           <h4 class="text-center"> | ||||
|             {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States | ||||
|           </h4> | ||||
|           <Pie | ||||
|             {useAltColors} | ||||
|             canvasId="hpcpie-slurm" | ||||
|             size={pieWidth * 0.55} | ||||
|             sliceLabel="Nodes" | ||||
|             quantities={refinedStateData.map( | ||||
|               (sd) => sd.count, | ||||
|             )} | ||||
|             entities={refinedStateData.map( | ||||
|               (sd) => sd.state, | ||||
|             )} | ||||
|           /> | ||||
|         {/key} | ||||
|       </div> | ||||
|     </Col> | ||||
|     <Col class="px-4 py-2"> | ||||
|       {#key refinedStateData} | ||||
|         <Table> | ||||
|           <tr class="mb-2"> | ||||
|             <th></th> | ||||
|             <th>Current State</th> | ||||
|             <th>Nodes</th> | ||||
|           </tr> | ||||
|           {#each refinedStateData as sd, i} | ||||
|             <tr> | ||||
|               <td><Icon name="circle-fill" style="color: {legendColors(i)};"/></td> | ||||
|               <td>{sd.state}</td> | ||||
|               <td>{sd.count}</td> | ||||
|             </tr> | ||||
|           {/each} | ||||
|         </Table> | ||||
|       {/key} | ||||
|     </Col> | ||||
|  | ||||
|     <Col class="px-3 mt-2 mt-lg-0"> | ||||
|       <div bind:clientWidth={pieWidth}> | ||||
|         {#key refinedHealthData} | ||||
|           <h4 class="text-center"> | ||||
|             {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node Health | ||||
|           </h4> | ||||
|           <Pie | ||||
|             {useAltColors} | ||||
|             canvasId="hpcpie-health" | ||||
|             size={pieWidth * 0.55} | ||||
|             sliceLabel="Nodes" | ||||
|             quantities={refinedHealthData.map( | ||||
|               (sd) => sd.count, | ||||
|             )} | ||||
|             entities={refinedHealthData.map( | ||||
|               (sd) => sd.state, | ||||
|             )} | ||||
|           /> | ||||
|         {/key} | ||||
|       </div> | ||||
|     </Col> | ||||
|     <Col class="px-4 py-2"> | ||||
|       {#key refinedHealthData} | ||||
|         <Table> | ||||
|           <tr class="mb-2"> | ||||
|             <th></th> | ||||
|             <th>Current Health</th> | ||||
|             <th>Nodes</th> | ||||
|           </tr> | ||||
|           {#each refinedHealthData as hd, i} | ||||
|             <tr> | ||||
|               <td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td> | ||||
|               <td>{hd.state}</td> | ||||
|               <td>{hd.count}</td> | ||||
|             </tr> | ||||
|           {/each} | ||||
|         </Table> | ||||
|       {/key} | ||||
|     </Col> | ||||
|   </Row> | ||||
| {/if} | ||||
|  | ||||
| <hr/> | ||||
| <!-- Gauges & Roofline per Subcluster--> | ||||
| {#if $initq.data && $statusQuery.data} | ||||
|   {#each $initq.data.clusters.find((c) => c.name == cluster).subClusters as subCluster, i} | ||||
|     <Row cols={{ lg: 3, md: 1 , sm: 1}} class="mb-3 justify-content-center"> | ||||
|       <Col class="px-3"> | ||||
|         <Card class="h-auto mt-1"> | ||||
|           <CardHeader> | ||||
|             <CardTitle class="mb-0">SubCluster "{subCluster.name}"</CardTitle> | ||||
|             <span>{subCluster.processorType}</span> | ||||
|           </CardHeader> | ||||
|           <CardBody> | ||||
|             <Table borderless> | ||||
|               <tr class="py-2"> | ||||
|                 <td style="font-size:x-large;">{runningJobs[subCluster.name]} Running Jobs</td> | ||||
|                 <td colspan="2" style="font-size:x-large;">{activeUsers[subCluster.name]} Active Users</td> | ||||
|               </tr> | ||||
|               <hr class="my-1"/> | ||||
|               <tr class="pt-2"> | ||||
|                 <td style="font-size: large;"> | ||||
|                   Flop Rate (<span style="cursor: help;" title="Flops[Any] = (Flops[Double] x 2) + Flops[Single]">Any</span>) | ||||
|                 </td> | ||||
|                 <td colspan="2" style="font-size: large;"> | ||||
|                   Memory BW Rate | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr class="pb-2"> | ||||
|                 <td style="font-size:x-large;"> | ||||
|                   {flopRate[subCluster.name]}  | ||||
|                   {flopRateUnitPrefix[subCluster.name]}{flopRateUnitBase[subCluster.name]} | ||||
|                 </td> | ||||
|                 <td colspan="2" style="font-size:x-large;"> | ||||
|                   {memBwRate[subCluster.name]}  | ||||
|                   {memBwRateUnitPrefix[subCluster.name]}{memBwRateUnitBase[subCluster.name]} | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <hr class="my-1"/> | ||||
|               <tr class="py-2"> | ||||
|                 <th scope="col">Allocated Nodes</th> | ||||
|                 <td style="min-width: 100px;" | ||||
|                   ><div class="col"> | ||||
|                     <Progress | ||||
|                       value={allocatedNodes[subCluster.name]} | ||||
|                       max={subCluster.numberOfNodes} | ||||
|                     /> | ||||
|                   </div></td | ||||
|                 > | ||||
|                 <td | ||||
|                   >{allocatedNodes[subCluster.name]} / {subCluster.numberOfNodes} | ||||
|                   Nodes</td | ||||
|                 > | ||||
|               </tr> | ||||
|               {#if totalAccs[subCluster.name] !== null} | ||||
|                 <tr class="py-2"> | ||||
|                   <th scope="col">Allocated Accelerators</th> | ||||
|                   <td style="min-width: 100px;" | ||||
|                     ><div class="col"> | ||||
|                       <Progress | ||||
|                         value={allocatedAccs[subCluster.name]} | ||||
|                         max={totalAccs[subCluster.name]} | ||||
|                       /> | ||||
|                     </div></td | ||||
|                   > | ||||
|                   <td | ||||
|                     >{allocatedAccs[subCluster.name]} / {totalAccs[subCluster.name]} | ||||
|                     Accelerators</td | ||||
|                   > | ||||
|                 </tr> | ||||
|               {/if} | ||||
|             </Table> | ||||
|           </CardBody> | ||||
|         </Card> | ||||
|       </Col> | ||||
|       <Col class="px-3 mt-2 mt-lg-0"> | ||||
|         <div bind:clientWidth={plotWidths[i]}> | ||||
|           {#key $statusQuery?.data?.nodeMetrics} | ||||
|             <Roofline | ||||
|               useColors={true} | ||||
|               allowSizeChange | ||||
|               width={plotWidths[i] - 10} | ||||
|               height={300} | ||||
|               cluster={cluster} | ||||
|               subCluster={subCluster} | ||||
|               roofData={transformNodesStatsToData($statusQuery?.data?.nodeMetrics.filter( | ||||
|                   (data) => data.subCluster == subCluster.name, | ||||
|                 ) | ||||
|               )} | ||||
|               nodesData={transformNodesStatsToInfo($statusQuery?.data?.nodeMetrics.filter( | ||||
|                   (data) => data.subCluster == subCluster.name, | ||||
|                 ) | ||||
|               )} | ||||
|             /> | ||||
|           {/key} | ||||
|         </div> | ||||
|       </Col> | ||||
|       <Col class="px-3 mt-2 mt-lg-0"> | ||||
|         <div bind:clientWidth={plotWidths[i]}> | ||||
|           {#key $statusQuery?.data?.jobsMetricStats} | ||||
|             <Roofline | ||||
|               useColors={true} | ||||
|               allowSizeChange | ||||
|               width={plotWidths[i] - 10} | ||||
|               height={300} | ||||
|               subCluster={subCluster} | ||||
|               roofData={transformJobsStatsToData($statusQuery?.data?.jobsMetricStats.filter( | ||||
|                   (data) => data.subCluster == subCluster.name, | ||||
|                 ) | ||||
|               )} | ||||
|               jobsData={transformJobsStatsToInfo($statusQuery?.data?.jobsMetricStats.filter( | ||||
|                   (data) => data.subCluster == subCluster.name, | ||||
|                 ) | ||||
|               )} | ||||
|             /> | ||||
|           {/key} | ||||
|         </div> | ||||
|       </Col> | ||||
|     </Row> | ||||
|   {/each} | ||||
| {:else} | ||||
|   <Card class="mx-4" body color="warning">Cannot render status rooflines: No data!</Card> | ||||
| {/if} | ||||
							
								
								
									
										547
									
								
								web/frontend/src/status/UsageDash.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										547
									
								
								web/frontend/src/status/UsageDash.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,547 @@ | ||||
| <!-- | ||||
|   @component Main cluster status view component; renders current system-usage information | ||||
|  | ||||
|   Properties: | ||||
|   - `presetCluster String`: The cluster to show status information for | ||||
| --> | ||||
|  | ||||
|  <script> | ||||
|   import { | ||||
|     Row, | ||||
|     Col, | ||||
|     Spinner, | ||||
|     Card, | ||||
|     Table, | ||||
|     Icon, | ||||
|     Tooltip, | ||||
|     Input, | ||||
|     InputGroup, | ||||
|     InputGroupText | ||||
|   } from "@sveltestrap/sveltestrap"; | ||||
|   import { | ||||
|     queryStore, | ||||
|     gql, | ||||
|     getContextClient, | ||||
|   } from "@urql/svelte"; | ||||
|   import { | ||||
|     init, | ||||
|     scramble, | ||||
|     scrambleNames, | ||||
|     convert2uplot, | ||||
|   } from "../generic/utils.js"; | ||||
|   import Pie, { colors } from "../generic/plots/Pie.svelte"; | ||||
|   import Histogram from "../generic/plots/Histogram.svelte"; | ||||
|   import Refresher from "../generic/helper/Refresher.svelte"; | ||||
|  | ||||
|   /* Svelte 5 Props */ | ||||
|   let { | ||||
|     presetCluster, | ||||
|     useCbColors = false, | ||||
|     useAltColors = false | ||||
|   } = $props(); | ||||
|  | ||||
|   /* Const Init */ | ||||
|   const { query: initq } = init(); | ||||
|   const client = getContextClient(); | ||||
|   const durationBinOptions = ["1m","10m","1h","6h","12h"]; | ||||
|  | ||||
|   /* State Init */ | ||||
|   let cluster = $state(presetCluster) | ||||
|   let from = $state(new Date(Date.now() - (30 * 24 * 60 * 60 * 1000))); // Simple way to retrigger GQL: Jobs Started last Month | ||||
|   let to = $state(new Date(Date.now())); | ||||
|   let colWidthJobs = $state(0); | ||||
|   let colWidthNodes = $state(0); | ||||
|   let colWidthAccs = $state(0); | ||||
|   let numDurationBins = $state("1h"); | ||||
|  | ||||
|   /* Derived */ | ||||
|   const topJobsQuery = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ( | ||||
|         $filter: [JobFilter!]! | ||||
|         $paging: PageRequest! | ||||
|       ) { | ||||
|         topUser: jobsStatistics( | ||||
|           filter: $filter | ||||
|           page: $paging | ||||
|           sortBy: TOTALJOBS | ||||
|           groupBy: USER | ||||
|         ) { | ||||
|           id | ||||
|           name | ||||
|           totalJobs | ||||
|         } | ||||
|         topProjects: jobsStatistics( | ||||
|           filter: $filter | ||||
|           page: $paging | ||||
|           sortBy: TOTALJOBS | ||||
|           groupBy: PROJECT | ||||
|         ) { | ||||
|           id | ||||
|           totalJobs | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       filter: [{ state: ["running"] }, { cluster: { eq: cluster}}, {startTime: { from, to }}], | ||||
|       paging: { itemsPerPage: 10, page: 1 } // Top 10 | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
|   const topNodesQuery = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ( | ||||
|         $filter: [JobFilter!]! | ||||
|         $paging: PageRequest! | ||||
|       ) { | ||||
|         topUser: jobsStatistics( | ||||
|           filter: $filter | ||||
|           page: $paging | ||||
|           sortBy: TOTALNODES | ||||
|           groupBy: USER | ||||
|         ) { | ||||
|           id | ||||
|           name | ||||
|           totalNodes | ||||
|         } | ||||
|         topProjects: jobsStatistics( | ||||
|           filter: $filter | ||||
|           page: $paging | ||||
|           sortBy: TOTALNODES | ||||
|           groupBy: PROJECT | ||||
|         ) { | ||||
|           id | ||||
|           totalNodes | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       filter: [{ state: ["running"] }, { cluster: { eq: cluster }}, {startTime: { from, to }}], | ||||
|       paging: { itemsPerPage: 10, page: 1 } // Top 10 | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
|   const topAccsQuery = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ( | ||||
|         $filter: [JobFilter!]! | ||||
|         $paging: PageRequest! | ||||
|       ) { | ||||
|         topUser: jobsStatistics( | ||||
|           filter: $filter | ||||
|           page: $paging | ||||
|           sortBy: TOTALACCS | ||||
|           groupBy: USER | ||||
|         ) { | ||||
|           id | ||||
|           name | ||||
|           totalAccs | ||||
|         } | ||||
|         topProjects: jobsStatistics( | ||||
|           filter: $filter | ||||
|           page: $paging | ||||
|           sortBy: TOTALACCS | ||||
|           groupBy: PROJECT | ||||
|         ) { | ||||
|           id | ||||
|           totalAccs | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       filter: [{ state: ["running"] }, { cluster: { eq: cluster }}, {startTime: { from, to }}], | ||||
|       paging: { itemsPerPage: 10, page: 1 } // Top 10 | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
|   // Note: nodeMetrics are requested on configured $timestep resolution | ||||
|   const nodeStatusQuery = $derived(queryStore({ | ||||
|     client: client, | ||||
|     query: gql` | ||||
|       query ( | ||||
|         $filter: [JobFilter!]! | ||||
|         $selectedHistograms: [String!] | ||||
|         $numDurationBins: String | ||||
|       ) { | ||||
|         jobsStatistics(filter: $filter, metrics: $selectedHistograms, numDurationBins: $numDurationBins) { | ||||
|           histDuration { | ||||
|             count | ||||
|             value | ||||
|           } | ||||
|           histNumNodes { | ||||
|             count | ||||
|             value | ||||
|           } | ||||
|           histNumAccs { | ||||
|             count | ||||
|             value | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|     variables: { | ||||
|       filter: [{ state: ["running"] }, { cluster: { eq: cluster }}, {startTime: { from, to }}], | ||||
|       selectedHistograms: [], // No Metrics requested for node hardware stats | ||||
|       numDurationBins: numDurationBins, | ||||
|     }, | ||||
|   })); | ||||
|  | ||||
|   /* Functions */ | ||||
|   function legendColors(targetIdx) { | ||||
|     // Reuses first color if targetIdx overflows | ||||
|     let c; | ||||
|       if (useCbColors) { | ||||
|         c = [...colors['colorblind']]; | ||||
|       } else if (useAltColors) { | ||||
|         c = [...colors['alternative']]; | ||||
|       } else { | ||||
|         c = [...colors['default']]; | ||||
|       } | ||||
|     return  c[(c.length + targetIdx) % c.length]; | ||||
|   } | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <!-- Refresher and space for other options --> | ||||
| <Row class="justify-content-between"> | ||||
|     <Col class="mb-2 mb-md-0" xs="12" md="5" lg="4" xl="3"> | ||||
|     <InputGroup> | ||||
|       <InputGroupText> | ||||
|         <Icon name="bar-chart-line-fill" /> | ||||
|       </InputGroupText> | ||||
|       <InputGroupText> | ||||
|         Duration Bin Size | ||||
|       </InputGroupText> | ||||
|       <Input type="select" bind:value={numDurationBins}> | ||||
|         {#each durationBinOptions as dbin} | ||||
|           <option value={dbin}>{dbin}</option> | ||||
|         {/each} | ||||
|       </Input> | ||||
|     </InputGroup> | ||||
|   </Col> | ||||
|   <Col xs="12" md="5" lg="4" xl="3"> | ||||
|     <Refresher | ||||
|       initially={120} | ||||
|       onRefresh={() => { | ||||
|         from = new Date(Date.now() - (30 * 24 * 60 * 60 * 1000)); // Triggers GQL | ||||
|         to = new Date(Date.now()); | ||||
|       }} | ||||
|     /> | ||||
|   </Col> | ||||
| </Row> | ||||
|  | ||||
| <hr/> | ||||
|  | ||||
| <!-- Job Duration, Top Users and Projects--> | ||||
| {#if $topJobsQuery.fetching || $nodeStatusQuery.fetching} | ||||
|   <Spinner /> | ||||
| {:else if $topJobsQuery.data && $nodeStatusQuery.data} | ||||
|   <Row> | ||||
|     <Col xs="12" lg="4" class="p-2"> | ||||
|       {#key $nodeStatusQuery.data.jobsStatistics[0].histDuration} | ||||
|         <Histogram | ||||
|           data={convert2uplot($nodeStatusQuery.data.jobsStatistics[0].histDuration)} | ||||
|           title="Duration Distribution" | ||||
|           xlabel="Current Job Runtimes" | ||||
|           xunit="Runtime" | ||||
|           ylabel="Number of Jobs" | ||||
|           yunit="Jobs" | ||||
|           height="275" | ||||
|           usesBins | ||||
|           xtime | ||||
|         /> | ||||
|       {/key} | ||||
|     </Col> | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <div bind:clientWidth={colWidthJobs}> | ||||
|         <h4 class="text-center"> | ||||
|           Top Users: Jobs | ||||
|         </h4> | ||||
|         <Pie | ||||
|           {useAltColors} | ||||
|           canvasId="hpcpie-jobs-users" | ||||
|           size={colWidthJobs * 0.75} | ||||
|           sliceLabel="Jobs" | ||||
|           quantities={$topJobsQuery.data.topUser.map( | ||||
|             (tu) => tu['totalJobs'], | ||||
|           )} | ||||
|           entities={$topJobsQuery.data.topUser.map((tu) => scrambleNames ? scramble(tu.id) : tu.id)} | ||||
|         /> | ||||
|       </div> | ||||
|     </Col> | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <Table> | ||||
|         <tr class="mb-2"> | ||||
|           <th></th> | ||||
|           <th style="padding-left: 0.5rem;">User</th> | ||||
|           <th>Jobs</th> | ||||
|         </tr> | ||||
|         {#each $topJobsQuery.data.topUser as tu, i} | ||||
|           <tr> | ||||
|             <td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td> | ||||
|             <td id="topName-jobs-{tu.id}"> | ||||
|               <a target="_blank" href="/monitoring/user/{tu.id}?cluster={cluster}&state=running" | ||||
|                 >{scrambleNames ? scramble(tu.id) : tu.id} | ||||
|               </a> | ||||
|             </td> | ||||
|             {#if tu?.name} | ||||
|               <Tooltip | ||||
|                 target={`topName-jobs-${tu.id}`} | ||||
|                 placement="left" | ||||
|                 >{scrambleNames ? scramble(tu.name) : tu.name}</Tooltip | ||||
|               > | ||||
|             {/if} | ||||
|             <td>{tu['totalJobs']}</td> | ||||
|           </tr> | ||||
|         {/each} | ||||
|       </Table> | ||||
|     </Col> | ||||
|  | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <h4 class="text-center"> | ||||
|         Top Projects: Jobs | ||||
|       </h4> | ||||
|       <Pie | ||||
|         {useAltColors} | ||||
|         canvasId="hpcpie-jobs-projects" | ||||
|         size={colWidthJobs * 0.75} | ||||
|         sliceLabel={'Jobs'} | ||||
|         quantities={$topJobsQuery.data.topProjects.map( | ||||
|           (tp) => tp['totalJobs'], | ||||
|         )} | ||||
|         entities={$topJobsQuery.data.topProjects.map((tp) => scrambleNames ? scramble(tp.id) : tp.id)} | ||||
|       /> | ||||
|     </Col> | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <Table> | ||||
|         <tr class="mb-2"> | ||||
|           <th></th> | ||||
|           <th style="padding-left: 0.5rem;">Project</th> | ||||
|           <th>Jobs</th> | ||||
|         </tr> | ||||
|         {#each $topJobsQuery.data.topProjects as tp, i} | ||||
|           <tr> | ||||
|             <td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td> | ||||
|             <td> | ||||
|               <a target="_blank" href="/monitoring/jobs/?cluster={cluster}&state=running&project={tp.id}&projectMatch=eq" | ||||
|                 >{scrambleNames ? scramble(tp.id) : tp.id} | ||||
|               </a> | ||||
|             </td> | ||||
|             <td>{tp['totalJobs']}</td> | ||||
|           </tr> | ||||
|         {/each} | ||||
|       </Table> | ||||
|     </Col> | ||||
|   </Row> | ||||
| {:else} | ||||
|   <Card class="mx-4" body color="warning">Cannot render job status charts: No data!</Card> | ||||
| {/if} | ||||
|  | ||||
| <hr/> | ||||
|  | ||||
| <!-- Node Distribution, Top Users and Projects--> | ||||
| {#if $topNodesQuery.fetching || $nodeStatusQuery.fetching} | ||||
|   <Spinner /> | ||||
| {:else if $topNodesQuery.data && $nodeStatusQuery.data} | ||||
|   <Row> | ||||
|     <Col xs="12" lg="4" class="p-2"> | ||||
|       <Histogram | ||||
|         data={convert2uplot($nodeStatusQuery.data.jobsStatistics[0].histNumNodes)} | ||||
|         title="Number of Nodes Distribution" | ||||
|         xlabel="Allocated Nodes" | ||||
|         xunit="Nodes" | ||||
|         ylabel="Number of Jobs" | ||||
|         yunit="Jobs" | ||||
|         height="275" | ||||
|       /> | ||||
|     </Col> | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <div bind:clientWidth={colWidthNodes}> | ||||
|         <h4 class="text-center"> | ||||
|           Top Users: Nodes | ||||
|         </h4> | ||||
|         <Pie | ||||
|           {useAltColors} | ||||
|           canvasId="hpcpie-nodes-users" | ||||
|           size={colWidthNodes * 0.75} | ||||
|           sliceLabel="Nodes" | ||||
|           quantities={$topNodesQuery.data.topUser.map( | ||||
|             (tu) => tu['totalNodes'], | ||||
|           )} | ||||
|           entities={$topNodesQuery.data.topUser.map((tu) => scrambleNames ? scramble(tu.id) : tu.id)} | ||||
|         /> | ||||
|       </div> | ||||
|     </Col> | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <Table> | ||||
|         <tr class="mb-2"> | ||||
|           <th></th> | ||||
|           <th style="padding-left: 0.5rem;">User</th> | ||||
|           <th>Nodes</th> | ||||
|         </tr> | ||||
|         {#each $topNodesQuery.data.topUser as tu, i} | ||||
|           <tr> | ||||
|             <td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td> | ||||
|             <td id="topName-nodes-{tu.id}"> | ||||
|               <a target="_blank" href="/monitoring/user/{tu.id}?cluster={cluster}&state=running" | ||||
|                 >{scrambleNames ? scramble(tu.id) : tu.id} | ||||
|               </a> | ||||
|             </td> | ||||
|             {#if tu?.name} | ||||
|               <Tooltip | ||||
|                 target={`topName-nodes-${tu.id}`} | ||||
|                 placement="left" | ||||
|                 >{scrambleNames ? scramble(tu.name) : tu.name}</Tooltip | ||||
|               > | ||||
|             {/if} | ||||
|             <td>{tu['totalNodes']}</td> | ||||
|           </tr> | ||||
|         {/each} | ||||
|       </Table> | ||||
|     </Col> | ||||
|  | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <h4 class="text-center"> | ||||
|         Top Projects: Nodes | ||||
|       </h4> | ||||
|       <Pie | ||||
|         {useAltColors} | ||||
|         canvasId="hpcpie-nodes-projects" | ||||
|         size={colWidthNodes * 0.75} | ||||
|         sliceLabel={'Nodes'} | ||||
|         quantities={$topNodesQuery.data.topProjects.map( | ||||
|           (tp) => tp['totalNodes'], | ||||
|         )} | ||||
|         entities={$topNodesQuery.data.topProjects.map((tp) => scrambleNames ? scramble(tp.id) : tp.id)} | ||||
|       /> | ||||
|     </Col> | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <Table> | ||||
|         <tr class="mb-2"> | ||||
|           <th></th> | ||||
|           <th style="padding-left: 0.5rem;">Project</th> | ||||
|           <th>Nodes</th> | ||||
|         </tr> | ||||
|         {#each $topNodesQuery.data.topProjects as tp, i} | ||||
|           <tr> | ||||
|             <td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td> | ||||
|             <td> | ||||
|               <a target="_blank" href="/monitoring/jobs/?cluster={cluster}&state=running&project={tp.id}&projectMatch=eq" | ||||
|                 >{scrambleNames ? scramble(tp.id) : tp.id} | ||||
|               </a> | ||||
|             </td> | ||||
|             <td>{tp['totalNodes']}</td> | ||||
|           </tr> | ||||
|         {/each} | ||||
|       </Table> | ||||
|     </Col> | ||||
|   </Row> | ||||
| {:else} | ||||
|   <Card class="mx-4" body color="warning">Cannot render node status charts: No data!</Card> | ||||
| {/if} | ||||
|  | ||||
| <hr/> | ||||
|  | ||||
| <!-- Acc Distribution, Top Users and Projects--> | ||||
| {#if $topAccsQuery.fetching || $nodeStatusQuery.fetching} | ||||
|   <Spinner /> | ||||
| {:else if $topAccsQuery.data && $nodeStatusQuery.data} | ||||
|   <Row> | ||||
|     <Col xs="12" lg="4" class="p-2"> | ||||
|       <Histogram | ||||
|         data={convert2uplot($nodeStatusQuery.data.jobsStatistics[0].histNumAccs)} | ||||
|         title="Number of Accelerators Distribution" | ||||
|         xlabel="Allocated Accs" | ||||
|         xunit="Accs" | ||||
|         ylabel="Number of Jobs" | ||||
|         yunit="Jobs" | ||||
|         height="275" | ||||
|       /> | ||||
|     </Col> | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <div bind:clientWidth={colWidthAccs}> | ||||
|         <h4 class="text-center"> | ||||
|           Top Users: GPUs | ||||
|         </h4> | ||||
|         <Pie | ||||
|           {useAltColors} | ||||
|           canvasId="hpcpie-accs-users" | ||||
|           size={colWidthAccs * 0.75} | ||||
|           sliceLabel="GPUs" | ||||
|           quantities={$topAccsQuery.data.topUser.map( | ||||
|             (tu) => tu['totalAccs'], | ||||
|           )} | ||||
|           entities={$topAccsQuery.data.topUser.map((tu) => scrambleNames ? scramble(tu.id) : tu.id)} | ||||
|         /> | ||||
|       </div> | ||||
|     </Col> | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <Table> | ||||
|         <tr class="mb-2"> | ||||
|           <th></th> | ||||
|           <th style="padding-left: 0.5rem;">User</th> | ||||
|           <th>GPUs</th> | ||||
|         </tr> | ||||
|         {#each $topAccsQuery.data.topUser as tu, i} | ||||
|           <tr> | ||||
|             <td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td> | ||||
|             <td id="topName-accs-{tu.id}"> | ||||
|               <a target="_blank" href="/monitoring/user/{tu.id}?cluster={cluster}&state=running" | ||||
|                 >{scrambleNames ? scramble(tu.id) : tu.id} | ||||
|               </a> | ||||
|             </td> | ||||
|             {#if tu?.name} | ||||
|               <Tooltip | ||||
|                 target={`topName-accs-${tu.id}`} | ||||
|                 placement="left" | ||||
|                 >{scrambleNames ? scramble(tu.name) : tu.name}</Tooltip | ||||
|               > | ||||
|             {/if} | ||||
|             <td>{tu['totalAccs']}</td> | ||||
|           </tr> | ||||
|         {/each} | ||||
|       </Table> | ||||
|     </Col> | ||||
|  | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <h4 class="text-center"> | ||||
|         Top Projects: GPUs | ||||
|       </h4> | ||||
|       <Pie | ||||
|         {useAltColors} | ||||
|         canvasId="hpcpie-accs-projects" | ||||
|         size={colWidthAccs * 0.75} | ||||
|         sliceLabel={'GPUs'} | ||||
|         quantities={$topAccsQuery.data.topProjects.map( | ||||
|           (tp) => tp['totalAccs'], | ||||
|         )} | ||||
|         entities={$topAccsQuery.data.topProjects.map((tp) => scrambleNames ? scramble(tp.id) : tp.id)} | ||||
|       /> | ||||
|     </Col> | ||||
|     <Col xs="6" md="3" lg="2" class="p-2"> | ||||
|       <Table> | ||||
|         <tr class="mb-2"> | ||||
|           <th></th> | ||||
|           <th style="padding-left: 0.5rem;">Project</th> | ||||
|           <th>GPUs</th> | ||||
|         </tr> | ||||
|         {#each $topAccsQuery.data.topProjects as tp, i} | ||||
|           <tr> | ||||
|             <td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td> | ||||
|             <td> | ||||
|               <a target="_blank" href="/monitoring/jobs/?cluster={cluster}&state=running&project={tp.id}&projectMatch=eq" | ||||
|                 >{scrambleNames ? scramble(tp.id) : tp.id} | ||||
|               </a> | ||||
|             </td> | ||||
|             <td>{tp['totalAccs']}</td> | ||||
|           </tr> | ||||
|         {/each} | ||||
|       </Table> | ||||
|     </Col> | ||||
|   </Row> | ||||
| {:else} | ||||
|   <Card class="mx-4" body color="warning">Cannot render accelerator status charts: No data!</Card> | ||||
| {/if} | ||||
		Reference in New Issue
	
	Block a user