-
Couldn't load subscription status.
- Fork 11
feat: add cpu power query & subscription #1745
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a03913c
87a66e3
ed5b2ca
1cc53c3
6b6c34c
c2c996a
f7f6f8e
d4b4778
924c201
53188c8
4b3634d
4716318
d48fd98
ca00849
1aa6525
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,211 @@ | ||||||||||||||||||
| import { Injectable, Logger } from '@nestjs/common'; | ||||||||||||||||||
| import { constants as fsConstants } from 'node:fs'; | ||||||||||||||||||
| import { access, readdir, readFile } from 'node:fs/promises'; | ||||||||||||||||||
|
||||||||||||||||||
| import { join } from 'path'; | ||||||||||||||||||
|
|
||||||||||||||||||
| @Injectable() | ||||||||||||||||||
| export class CpuTopologyService { | ||||||||||||||||||
| private readonly logger = new Logger(CpuTopologyService.name); | ||||||||||||||||||
|
|
||||||||||||||||||
| private topologyCache: { id: number; cores: number[][] }[] | null = null; | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Remove unused field. The - private topologyCache: { id: number; cores: number[][] }[] | null = null;
-📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| // ----------------------------------------------------------------- | ||||||||||||||||||
| // Read static CPU topology, per-package core thread pairs | ||||||||||||||||||
| // ----------------------------------------------------------------- | ||||||||||||||||||
| async generateTopology(): Promise<number[][][]> { | ||||||||||||||||||
| const packages: Record<number, number[][]> = {}; | ||||||||||||||||||
| const cpuDirs = await readdir('/sys/devices/system/cpu'); | ||||||||||||||||||
|
|
||||||||||||||||||
| for (const dir of cpuDirs) { | ||||||||||||||||||
| if (!/^cpu\d+$/.test(dir)) continue; | ||||||||||||||||||
|
|
||||||||||||||||||
| const basePath = join('/sys/devices/system/cpu', dir, 'topology'); | ||||||||||||||||||
| const pkgFile = join(basePath, 'physical_package_id'); | ||||||||||||||||||
| const siblingsFile = join(basePath, 'thread_siblings_list'); | ||||||||||||||||||
|
|
||||||||||||||||||
| try { | ||||||||||||||||||
| const [pkgIdStr, siblingsStrRaw] = await Promise.all([ | ||||||||||||||||||
| readFile(pkgFile, 'utf8'), | ||||||||||||||||||
| readFile(siblingsFile, 'utf8'), | ||||||||||||||||||
| ]); | ||||||||||||||||||
|
|
||||||||||||||||||
| const pkgId = parseInt(pkgIdStr.trim(), 10); | ||||||||||||||||||
|
|
||||||||||||||||||
| // expand ranges | ||||||||||||||||||
| const siblings = siblingsStrRaw | ||||||||||||||||||
| .trim() | ||||||||||||||||||
| .replace(/(\d+)-(\d+)/g, (_, start, end) => | ||||||||||||||||||
| Array.from( | ||||||||||||||||||
| { length: parseInt(end) - parseInt(start) + 1 }, | ||||||||||||||||||
| (_, i) => parseInt(start) + i | ||||||||||||||||||
| ).join(',') | ||||||||||||||||||
| ) | ||||||||||||||||||
| .split(',') | ||||||||||||||||||
| .map((n) => parseInt(n, 10)); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!packages[pkgId]) packages[pkgId] = []; | ||||||||||||||||||
| if (!packages[pkgId].some((arr) => arr.join(',') === siblings.join(','))) { | ||||||||||||||||||
| packages[pkgId].push(siblings); | ||||||||||||||||||
| } | ||||||||||||||||||
| } catch (err) { | ||||||||||||||||||
| console.warn('Topology read error for', dir, err); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Use consistent logging: prefer The rest of the file uses - console.warn('Topology read error for', dir, err);
+ this.logger.warn('Topology read error for', dir, err);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| // Sort cores within each package, and packages by their lowest core index | ||||||||||||||||||
| const result = Object.entries(packages) | ||||||||||||||||||
| .sort((a, b) => a[1][0][0] - b[1][0][0]) // sort packages by first CPU ID | ||||||||||||||||||
| .map( | ||||||||||||||||||
| ([pkgId, cores]) => cores.sort((a, b) => a[0] - b[0]) // sort cores within package | ||||||||||||||||||
| ); | ||||||||||||||||||
|
|
||||||||||||||||||
| return result; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // ----------------------------------------------------------------- | ||||||||||||||||||
| // Dynamic telemetry (power + temperature) | ||||||||||||||||||
| // ----------------------------------------------------------------- | ||||||||||||||||||
| private async getPackageTemps(): Promise<number[]> { | ||||||||||||||||||
| const temps: number[] = []; | ||||||||||||||||||
| try { | ||||||||||||||||||
| const hwmons = await readdir('/sys/class/hwmon'); | ||||||||||||||||||
| for (const hwmon of hwmons) { | ||||||||||||||||||
| const path = join('/sys/class/hwmon', hwmon); | ||||||||||||||||||
| try { | ||||||||||||||||||
| const label = (await readFile(join(path, 'name'), 'utf8')).trim(); | ||||||||||||||||||
| if (/coretemp|k10temp|zenpower/i.test(label)) { | ||||||||||||||||||
| const files = await readdir(path); | ||||||||||||||||||
| for (const f of files) { | ||||||||||||||||||
| if (f.startsWith('temp') && f.endsWith('_label')) { | ||||||||||||||||||
| const lbl = (await readFile(join(path, f), 'utf8')).trim().toLowerCase(); | ||||||||||||||||||
| if ( | ||||||||||||||||||
| lbl.includes('package id') || | ||||||||||||||||||
| lbl.includes('tctl') || | ||||||||||||||||||
| lbl.includes('tdie') | ||||||||||||||||||
| ) { | ||||||||||||||||||
| const inputFile = join(path, f.replace('_label', '_input')); | ||||||||||||||||||
| try { | ||||||||||||||||||
| const raw = await readFile(inputFile, 'utf8'); | ||||||||||||||||||
| temps.push(parseInt(raw.trim(), 10) / 1000); | ||||||||||||||||||
| } catch (err) { | ||||||||||||||||||
| this.logger.warn('Failed to read file', err); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } catch (err) { | ||||||||||||||||||
| this.logger.warn('Failed to read file', err); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } catch (err) { | ||||||||||||||||||
| this.logger.warn('Failed to read file', err); | ||||||||||||||||||
| } | ||||||||||||||||||
| return temps; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private async getPackagePower(): Promise<Record<number, Record<string, number>>> { | ||||||||||||||||||
| const basePath = '/sys/class/powercap'; | ||||||||||||||||||
| const prefixes = ['intel-rapl', 'intel-rapl-mmio', 'amd-rapl']; | ||||||||||||||||||
| const raplPaths: string[] = []; | ||||||||||||||||||
|
|
||||||||||||||||||
| try { | ||||||||||||||||||
| const entries = await readdir(basePath, { withFileTypes: true }); | ||||||||||||||||||
| for (const entry of entries) { | ||||||||||||||||||
| if (entry.isSymbolicLink() && prefixes.some((p) => entry.name.startsWith(p))) { | ||||||||||||||||||
| if (/:\d+:\d+/.test(entry.name)) continue; | ||||||||||||||||||
| raplPaths.push(join(basePath, entry.name)); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } catch { | ||||||||||||||||||
| return {}; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!raplPaths.length) return {}; | ||||||||||||||||||
|
|
||||||||||||||||||
| const readEnergy = async (p: string): Promise<number | null> => { | ||||||||||||||||||
| try { | ||||||||||||||||||
| await access(join(p, 'energy_uj'), fsConstants.R_OK); | ||||||||||||||||||
| const raw = await readFile(join(p, 'energy_uj'), 'utf8'); | ||||||||||||||||||
| return parseInt(raw.trim(), 10); | ||||||||||||||||||
| } catch { | ||||||||||||||||||
| return null; | ||||||||||||||||||
| } | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| const prevE = new Map<string, number>(); | ||||||||||||||||||
| const prevT = new Map<string, bigint>(); | ||||||||||||||||||
|
|
||||||||||||||||||
| for (const p of raplPaths) { | ||||||||||||||||||
| const val = await readEnergy(p); | ||||||||||||||||||
| if (val !== null) { | ||||||||||||||||||
| prevE.set(p, val); | ||||||||||||||||||
| prevT.set(p, process.hrtime.bigint()); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| await new Promise((res) => setTimeout(res, 100)); | ||||||||||||||||||
|
|
||||||||||||||||||
| const results: Record<number, Record<string, number>> = {}; | ||||||||||||||||||
|
|
||||||||||||||||||
| for (const p of raplPaths) { | ||||||||||||||||||
| const now = await readEnergy(p); | ||||||||||||||||||
| if (now === null) continue; | ||||||||||||||||||
|
|
||||||||||||||||||
| const prevVal = prevE.get(p); | ||||||||||||||||||
| const prevTime = prevT.get(p); | ||||||||||||||||||
| if (prevVal === undefined || prevTime === undefined) continue; | ||||||||||||||||||
|
|
||||||||||||||||||
| const diffE = now - prevVal; | ||||||||||||||||||
| const diffT = Number(process.hrtime.bigint() - prevTime); | ||||||||||||||||||
| if (diffT <= 0 || diffE < 0) continue; | ||||||||||||||||||
|
|
||||||||||||||||||
| const watts = (diffE * 1e-6) / (diffT * 1e-9); | ||||||||||||||||||
| const powerW = Math.round(watts * 100) / 100; | ||||||||||||||||||
|
|
||||||||||||||||||
| const nameFile = join(p, 'name'); | ||||||||||||||||||
| let label = 'package'; | ||||||||||||||||||
| try { | ||||||||||||||||||
| label = (await readFile(nameFile, 'utf8')).trim(); | ||||||||||||||||||
| } catch (err) { | ||||||||||||||||||
| this.logger.warn('Failed to read file', err); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const pkgMatch = label.match(/package-(\d+)/i); | ||||||||||||||||||
| const pkgId = pkgMatch ? Number(pkgMatch[1]) : 0; | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!results[pkgId]) results[pkgId] = {}; | ||||||||||||||||||
| results[pkgId][label] = powerW; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| for (const domains of Object.values(results)) { | ||||||||||||||||||
| const total = Object.values(domains).reduce((a, b) => a + b, 0); | ||||||||||||||||||
| (domains as any)['total'] = Math.round(total * 100) / 100; | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+180
to
+183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid Line 184 uses The - for (const domains of Object.values(results)) {
+ for (const domains of Object.values(results) as Record<string, number>[]) {
const total = Object.values(domains).reduce((a, b) => a + b, 0);
- (domains as any)['total'] = Math.round(total * 100) / 100;
+ domains['total'] = Math.round(total * 100) / 100;
}Based on coding guidelines. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| return results; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| async generateTelemetry(): Promise<{ id: number; power: number; temp: number }[]> { | ||||||||||||||||||
| const temps = await this.getPackageTemps(); | ||||||||||||||||||
| const powerData = await this.getPackagePower(); | ||||||||||||||||||
|
|
||||||||||||||||||
| const maxPkg = Math.max(temps.length - 1, ...Object.keys(powerData).map(Number), 0); | ||||||||||||||||||
|
|
||||||||||||||||||
| const result: { | ||||||||||||||||||
| id: number; | ||||||||||||||||||
| power: number; | ||||||||||||||||||
| temp: number; | ||||||||||||||||||
| }[] = []; | ||||||||||||||||||
|
|
||||||||||||||||||
| for (let pkgId = 0; pkgId <= maxPkg; pkgId++) { | ||||||||||||||||||
| const entry = powerData[pkgId] ?? {}; | ||||||||||||||||||
| result.push({ | ||||||||||||||||||
| id: pkgId, | ||||||||||||||||||
| power: entry.total ?? -1, | ||||||||||||||||||
| temp: temps[pkgId] ?? -1, | ||||||||||||||||||
| }); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| return result; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,18 @@ export class CpuLoad { | |
| percentSteal!: number; | ||
| } | ||
|
|
||
| @ObjectType() | ||
| export class CpuPackages { | ||
| @Field(() => Float, { description: 'Total CPU package power draw (W)' }) | ||
| totalPower?: number; | ||
|
|
||
| @Field(() => [Float], { description: 'Power draw per package (W)' }) | ||
| power?: number[]; | ||
|
|
||
| @Field(() => [Float], { description: 'Temperature per package (°C)' }) | ||
| temp?: number[]; | ||
| } | ||
|
Comment on lines
+42
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Make CpuPackages a Node type and align nullability/casing. Implement Node and make fields definite to match GraphQL expectations; we already return these arrays non-null. -@ObjectType()
-export class CpuPackages {
- @Field(() => Float, { description: 'Total CPU package power draw (W)' })
- totalPower?: number;
- @Field(() => [Float], { description: 'Power draw per package (W)' })
- power?: number[];
- @Field(() => [Float], { description: 'Temperature per package (°C)' })
- temp?: number[];
-}
+@ObjectType({ implements: () => Node })
+export class CpuPackages extends Node {
+ @Field(() => Float, { description: 'Total CPU package power draw (W)' })
+ totalPower!: number;
+ @Field(() => [Float], { description: 'Power draw per package (W)' })
+ power!: number[];
+ @Field(() => [Float], { description: 'Temperature per package (°C)' })
+ temp!: number[];
+}
🤖 Prompt for AI Agents |
||
|
|
||
| @ObjectType({ implements: () => Node }) | ||
| export class CpuUtilization extends Node { | ||
| @Field(() => Float, { description: 'Total CPU load in percent' }) | ||
|
|
@@ -100,4 +112,12 @@ export class InfoCpu extends Node { | |
|
|
||
| @Field(() => [String], { nullable: true, description: 'CPU feature flags' }) | ||
| flags?: string[]; | ||
|
|
||
| @Field(() => [[[Int]]], { | ||
| description: 'Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]]', | ||
| }) | ||
| topology!: number[][][]; | ||
|
|
||
| @Field(() => CpuPackages) | ||
| packages!: CpuPackages; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { Module } from '@nestjs/common'; | ||
|
|
||
| import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; | ||
| import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; | ||
|
|
||
| @Module({ | ||
| providers: [CpuService, CpuTopologyService], | ||
| exports: [CpuService, CpuTopologyService], | ||
| }) | ||
| export class CpuModule {} |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,6 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| vi.mock('systeminformation', () => ({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -88,9 +89,27 @@ vi.mock('systeminformation', () => ({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe('CpuService', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let service: CpuService; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let cpuTopologyService: CpuTopologyService; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| beforeEach(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| service = new CpuService(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cpuTopologyService = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| generateTopology: vi.fn().mockResolvedValue([ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [0, 1], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [2, 3], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [4, 5], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [6, 7], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| generateTelemetry: vi.fn().mockResolvedValue([ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { power: 32.5, temp: 45.0 }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { power: 33.0, temp: 46.0 }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } as any; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+95
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Replace The coding guideline states to never use the As per coding guidelines. Apply this diff: cpuTopologyService = {
generateTopology: vi.fn().mockResolvedValue([
[
[0, 1],
[2, 3],
],
[
[4, 5],
[6, 7],
],
]),
generateTelemetry: vi.fn().mockResolvedValue([
{ power: 32.5, temp: 45.0 },
{ power: 33.0, temp: 46.0 },
]),
- } as any;
+ } as unknown as CpuTopologyService;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| service = new CpuService(cpuTopologyService); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe('generateCpu', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -121,6 +140,21 @@ describe('CpuService', () => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| l3: 12582912, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| flags: ['fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce', 'cx8'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| packages: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| totalPower: 65.5, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| power: [32.5, 33.0], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| temp: [45.0, 46.0], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| topology: [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [0, 1], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [2, 3], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [4, 5], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [6, 7], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.