Merge branch 'master' into feat/dashboard/folders

This commit is contained in:
Arnaud 2024-10-14 20:41:40 +02:00
commit e08fd6cf1e
No known key found for this signature in database
GPG Key ID: 69D6CE281FCAE663
68 changed files with 4171 additions and 284 deletions

136
.github/workflows/playwright.yml vendored Normal file
View File

@ -0,0 +1,136 @@
name: Playwright Tests
on:
push:
branches: [main, master, feat/tests/test-init, releases/*]
pull_request:
branches: [main, master, releases/*]
workflow_dispatch:
env:
codex_version: v0.1.6
circuit_version: v0.1.6
marketplace_address: "0xfE822Df439d987849a90B64a4C0e26a297DBD47F"
eth_provider: "https://rpc.testnet.codex.storage"
VITE_CODEX_API_URL: ${{ secrets.VITE_CODEX_API_URL }}
VITE_GEO_IP_URL: ${{ secrets.VITE_GEO_IP_URL }}
jobs:
tests:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- name: Install dependencies
run: sudo apt update && sudo apt install libgomp1
- name: Setup env variables
run: |
echo "platform=${RUNNER_OS,,}" >> $GITHUB_ENV
echo "architecture=$([[ $RUNNER_ARCH == 'X64' ]] && echo amd64 || echo ${RUNNER_ARCH,,})" >> $GITHUB_ENV
- name: Restore cached circuits
id: circuits-cache-restore
uses: actions/cache/restore@v4
with:
path: |
datadir/circuits
key: ${{ env.circuit_version }}-circuits
- name: Download circuits
if: steps.circuits-cache-restore.outputs.cache-hit != 'true'
run: |
mkdir -p datadir/circuits
chmod 700 datadir
chmod 700 datadir/circuits
curl -OL https://github.com/codex-storage/nim-codex/releases/download/${circuit_version}/cirdl-${circuit_version}-${platform}-${architecture}.tar.gz
tar -xvf cirdl-${circuit_version}-${platform}-${architecture}.tar.gz
chmod +x cirdl-${circuit_version}-${platform}-${architecture}
./cirdl-${circuit_version}-${platform}-${architecture} ./datadir/circuits ${eth_provider} ${marketplace_address}
- name: Cache circuits
id: circuits-cache
uses: actions/cache/save@v4
with:
path: datadir/circuits
key: ${{ steps.circuits-cache-restore.outputs.cache-primary-key }}
- name: Restore cached codex
id: codex-cache-restore
uses: actions/cache/restore@v4
with:
path: ./codex-${{ env.codex_version }}-${{ env.platform }}-${{ env.architecture }}
key: ${{ env.codex_version }}-codex
- name: Download codex
if: steps.codex-cache-restore.outputs.cache-hit != 'true'
run: |
curl -LO https://github.com/codex-storage/nim-codex/releases/download/${codex_version}/codex-${codex_version}-${platform}-${architecture}.tar.gz
curl -LO https://github.com/codex-storage/nim-codex/releases/download/${codex_version}/codex-${codex_version}-${platform}-${architecture}.tar.gz.sha256
sha256sum -c codex-${codex_version}-${platform}-${architecture}.tar.gz.sha256
[[ $? -eq 0 ]] && { echo "Checksum is OK"; } || { echo "Checksum failed"; exit 1; }
tar -zxvf codex-${codex_version}-${platform}-${architecture}.tar.gz
- name: Cache codex
id: codex-cache
uses: actions/cache/save@v4
with:
path: ./codex-${{ env.codex_version }}-${{ env.platform }}-${{ env.architecture }}
key: ${{ steps.codex-cache-restore.outputs.cache-primary-key }}
- name: Run Codex node
run: |
# Get an eth address
response=$(curl -s https://key.codex.storage)
awk -F ': ' '/private/ {print $2}' <<<"${response}" > eth.key
awk -F ': ' '/address/ {print $2}' <<<"${response}" > eth.address
chmod 600 eth.key
# Run
./codex-${codex_version}-${platform}-${architecture} --data-dir=datadir --api-cors-origin="*" persistence --eth-provider=${eth_provider} --eth-private-key=./eth.key --marketplace-address=${marketplace_address} prover --circuit-dir=./datadir/circuits &
sleep 15
- name: Check Codex API
run: |
curl --max-time 5 --fail localhost:8080/api/codex/v1/debug/info -w "\n"
[[ $? -eq 0 ]] && { echo "Codex node is up"; } || { echo "Please check Codex node"; exit 1; }
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- uses: actions/cache@v4
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
# - uses: actions/cache@v4
# id: playwright-browsers-cache
# with:
# path: ~/.cache/ms-playwright
# key: ${{ runner.os }}-node-${{ hashFiles('**/playwright.config.ts') }}
# - if: steps.playwright-browsers-cache.outputs.cache-hit != 'true'
# name: Install Playwright Browsers
# run: npx playwright install --with-deps
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run test
- uses: actions/upload-artifact@v4
# if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

4
.gitignore vendored
View File

@ -22,3 +22,7 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

BIN
e2e/assets/chatgpt.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

BIN
e2e/assets/mountain.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,56 @@
import test, { expect } from "@playwright/test";
test('creates an availability', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('link', { name: 'Sales' }).click();
await page.getByRole('button').first().click();
await page.getByLabel('Total size').click();
await page.getByLabel('Total size').fill('0.50');
await page.getByLabel('Duration').click();
await page.getByLabel('Duration').fill('30');
await page.getByLabel('Min price').click();
await page.getByLabel('Min price').fill('5');
await page.getByLabel('Max collateral').click();
await page.getByLabel('Max collateral').fill('30');
await page.getByLabel('Min price').fill('5');
await page.getByLabel('Nickname').click();
await page.getByLabel('Nickname').fill('test');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByText('Confirm your new sale')).toBeVisible();
await page.locator('small').filter({ hasText: /^512\.0 MB$/ }).click();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByText('Success', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Finish' }).click();
await expect(page.getByText('512.0 MB allocated for the').first()).toBeVisible();
})
test('availability navigation buttons', async ({ page }) => {
await page.goto('/dashboard/availabilities');
await page.getByRole('button').first().click();
await expect(page.locator('.stepper-number-done')).not.toBeVisible()
await expect(page.locator('.stepper-number-active')).toBeVisible()
await expect(page.locator('.stepper-buttons .button--primary')).not.toHaveAttribute("disabled");
await expect(page.locator('.stepper-buttons .button--outline')).not.toHaveAttribute("disabled");
await page.getByLabel('Total size').click();
await page.getByLabel('Total size').fill('19');
await expect(page.locator('.stepper-buttons .button--outline')).not.toHaveAttribute("disabled");
await expect(page.locator('.stepper-buttons .button--primary')).toHaveAttribute("disabled");
await page.getByLabel('Total size').click();
await page.getByLabel('Total size').fill('0.5');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.stepper-buttons .button--outline')).not.toHaveAttribute("disabled");
await expect(page.locator('.stepper-buttons .button--primary')).not.toHaveAttribute("disabled");
await expect(page.locator('.stepper-number-done')).toBeVisible()
await expect(page.locator('.stepper-number-active')).toBeVisible()
await page.getByRole('button', { name: 'Back' }).click();
await expect(page.locator('.stepper-number-done')).not.toBeVisible()
await expect(page.locator('.stepper-number-active')).toBeVisible()
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.stepper-number-done')).toHaveCount(2)
await expect(page.locator('.stepper-number-active')).toBeVisible()
await expect(page.locator('.stepper-buttons .button--outline')).toHaveAttribute("disabled");
await expect(page.locator('.stepper-buttons .button--primary')).not.toHaveAttribute("disabled");
await page.getByRole('button', { name: 'Finish' }).click();
await expect(page.locator('.modal--open')).not.toBeVisible();
})

23
e2e/settings.spec.ts Normal file
View File

@ -0,0 +1,23 @@
import test, { expect } from "@playwright/test";
test('update the log level', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('link', { name: 'Settings' }).click();
await page.getByLabel('Log level').selectOption('TRACE');
await page.getByRole('main').locator('div').filter({ hasText: 'Log' }).getByRole('button').click();
await expect(page.locator('span').filter({ hasText: 'success ! The log level has' }).locator('b')).toBeVisible();
})
test('update the URL with wrong URL applies', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('link', { name: 'Settings' }).click();
await page.getByLabel('Codex client node URL').click();
await page.getByLabel('Codex client node URL').fill('http://127.0.0.1:8079');
await page.getByRole('button', { name: 'Save changes' }).nth(1).click();
await expect.soft(page.getByText("Cannot retrieve the data")).toBeVisible()
await page.getByLabel('Codex client node URL').fill('http://127.0.0.1:8080');
await page.getByRole('button', { name: 'Save changes' }).nth(1).click();
})

View File

@ -0,0 +1,77 @@
import test, { expect } from "@playwright/test";
import path, { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
test('creates a storage request', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('link', { name: 'Purchases' }).click();
await page.getByRole('button', { name: 'Storage Request' }).click();
await page.locator('div').getByTestId("upload").setInputFiles([
path.join(__dirname, "assets", 'chatgpt.jpg'),
]);
await expect(page.locator('#cid')).toHaveValue("zDvZRwzkvwapyNeL4mzw5gBsZvyn7x8F8Y9n4RYSC7ETBssDYpGe")
await expect(page.getByText('Success, the CID has been')).toBeVisible();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByText('Your request is being processed.')).toBeVisible();
await page.getByRole('button', { name: 'Finish' }).click();
await expect(page.getByText('No data.')).not.toBeVisible();
await page.getByRole('cell', { name: 'pending' }).getByRole('paragraph').click();
})
test('select a uploaded cid when creating a storage request', async ({ page }) => {
await page.goto('/dashboard');
await page.locator('div').getByTestId("upload").setInputFiles([
path.join(__dirname, "assets", 'chatgpt.jpg'),
]);
await page.getByRole('link', { name: 'Purchases' }).click();
await page.getByRole('button', { name: 'Storage Request' }).click();
await page.getByPlaceholder('Select or type your CID').click();
await page.getByText('N/A0').click();
await expect(page.getByText('button[disabled]')).not.toBeVisible();
})
test('storage request navigation buttons', async ({ page }) => {
await page.goto('/dashboard/purchases');
await page.getByRole('button', { name: 'Storage Request' }).click();
await expect(page.locator('.stepper-number-done')).not.toBeVisible()
await expect(page.locator('.stepper-number-active')).toBeVisible()
await expect(page.locator('.stepper-buttons .button--primary')).toHaveAttribute("disabled");
await expect(page.locator('.stepper-buttons .button--outline')).not.toHaveAttribute("disabled");
await page.locator('div').getByTestId("upload").setInputFiles([
path.join(__dirname, "assets", 'chatgpt.jpg'),
]);
await expect(page.locator('.stepper-buttons .button--outline')).not.toHaveAttribute("disabled");
await expect(page.locator('.stepper-buttons .button--primary')).not.toHaveAttribute("disabled");
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.stepper-buttons .button--outline')).not.toHaveAttribute("disabled");
await expect(page.locator('.stepper-buttons .button--primary')).not.toHaveAttribute("disabled");
await expect(page.locator('.stepper-number-done')).toBeVisible()
await expect(page.locator('.stepper-number-active')).toBeVisible()
await page.getByRole('button', { name: 'Back' }).click();
await expect(page.locator('.stepper-number-done')).not.toBeVisible()
await expect(page.locator('.stepper-number-active')).toBeVisible()
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.stepper-number-done')).toHaveCount(2)
await expect(page.locator('.stepper-number-active')).toBeVisible()
await expect(page.locator('.stepper-buttons .button--outline')).toHaveAttribute("disabled");
await expect(page.locator('.stepper-buttons .button--primary')).not.toHaveAttribute("disabled");
await page.getByRole('button', { name: 'Finish' }).click();
await expect(page.locator('.modal--open')).not.toBeVisible();
})
test('remove the CID when the file is deleted', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('link', { name: 'Purchases' }).click();
await page.getByRole('button', { name: 'Storage Request' }).click();
await page.locator('div').getByTestId("upload").setInputFiles([
path.join(__dirname, "assets", 'chatgpt.jpg'),
]);
await expect(page.locator('#cid')).toHaveValue("zDvZRwzkvwapyNeL4mzw5gBsZvyn7x8F8Y9n4RYSC7ETBssDYpGe")
await page.locator('.uploadFile-infoRight .buttonIcon--small').click();
await expect(page.locator('#cid')).toHaveValue("")
})

69
e2e/upload.spec.ts Normal file
View File

@ -0,0 +1,69 @@
import { test, expect } from '@playwright/test';
import { readFileSync } from 'fs';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
test('upload one file', async ({ page }) => {
await page.goto('/dashboard');
await page.locator('div').getByTestId("upload").setInputFiles([
path.join(__dirname, "assets", 'chatgpt.jpg'),
]);
await expect(page.getByText('File uploaded successfully')).toBeVisible();
});
test('multiple files upload', async ({ page }) => {
await page.goto('/dashboard');
await page.locator('div').getByTestId("upload").setInputFiles([
path.join(__dirname, "assets", 'chatgpt.jpg'),
path.join(__dirname, "assets", 'mountain.jpeg'),
]);
await expect(page.getByText('File uploaded successfully').first()).toBeVisible();
await expect(page.getByText('File uploaded successfully').nth(1)).toBeVisible();
await page.locator('.uploadFile-infoRight > .buttonIcon').first().click();
await page.locator('.uploadFile-infoRight > .buttonIcon').click();
await expect(page.getByText('File uploaded successfully').first()).not.toBeVisible();
await expect(page.getByText('File uploaded successfully').nth(1)).not.toBeVisible();
});
test('drag and drop file', async ({ page }) => {
await page.goto('/dashboard');
const buffer = readFileSync(path.join(__dirname, "assets", 'chatgpt.jpg'));
// Create the DataTransfer and File
const dataTransfer = await page.evaluateHandle((data) => {
const dt = new DataTransfer();
// Convert the buffer to a hex array
const file = new File([data.toString('hex')], 'chat.jpg', { type: 'image/jpg' });
dt.items.add(file);
return dt;
}, buffer);
await page.dispatchEvent('input[type="file"]', 'drop', { dataTransfer });
await expect(page.getByText('File uploaded successfully').first()).toBeVisible();
});
// test('stop an upload display a message', async ({ page }) => {
// await page.goto('/dashboard');
// const buffer = Buffer.alloc(50_000_000);
// await page.locator('div').getByTestId("upload").setInputFiles({
// buffer,
// name: "test.txt",
// mimeType: 'text/plain'
// });
// await page.locator('.uploadFile-infoRight > .buttonIcon--small').click();
// await expect(page.getByText('The upload has been cancelled')).toBeVisible();
// });

564
package-lock.json generated
View File

@ -1,38 +1,43 @@
{ {
"name": "@codex-storage/marketplace-ui", "name": "@codex-storage/marketplace-ui",
"version": "0.0.3", "version": "0.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@codex-storage/marketplace-ui", "name": "@codex-storage/marketplace-ui",
"version": "0.0.3", "version": "0.0.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codex-storage/marketplace-ui-components": "0.0.18", "@codex-storage/marketplace-ui-components": "^0.0.18",
"@codex-storage/sdk-js": "^0.0.7", "@codex-storage/sdk-js": "^0.0.8",
"@sentry/browser": "^8.32.0", "@sentry/browser": "^8.32.0",
"@sentry/react": "^8.31.0", "@sentry/react": "^8.31.0",
"@tanstack/react-query": "^5.51.15", "@tanstack/react-query": "^5.51.15",
"@tanstack/react-router": "^1.58.7", "@tanstack/react-router": "^1.58.7",
"dotted-map": "^2.2.3", "dotted-map": "^2.2.3",
"echarts": "^5.5.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.445.0", "lucide-react": "^0.445.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.48.0",
"@tanstack/router-devtools": "^1.58.7", "@tanstack/router-devtools": "^1.58.7",
"@tanstack/router-plugin": "^1.58.4", "@tanstack/router-plugin": "^1.58.4",
"@types/node": "^22.7.5",
"@types/react": "^18.3.8", "@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.7.0", "@typescript-eslint/eslint-plugin": "^8.7.0",
"@typescript-eslint/parser": "^8.7.0", "@typescript-eslint/parser": "^8.7.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"dotenv": "^16.4.5",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.12", "eslint-plugin-react-refresh": "^0.4.12",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"sass-embedded": "^1.79.4",
"typescript": "5.5.4", "typescript": "5.5.4",
"vite": "^5.4.7" "vite": "^5.4.7"
}, },
@ -342,6 +347,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bufbuild/protobuf": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.1.0.tgz",
"integrity": "sha512-+2Mx67Y3skJ4NCD/qNSdBJNWtu6x6Qr53jeNg+QcwiL6mt0wK+3jwHH2x1p7xaYH6Ve2JKOVn0OxU35WsmqI9A==",
"dev": true
},
"node_modules/@codex-storage/marketplace-ui-components": { "node_modules/@codex-storage/marketplace-ui-components": {
"version": "0.0.18", "version": "0.0.18",
"resolved": "https://registry.npmjs.org/@codex-storage/marketplace-ui-components/-/marketplace-ui-components-0.0.18.tgz", "resolved": "https://registry.npmjs.org/@codex-storage/marketplace-ui-components/-/marketplace-ui-components-0.0.18.tgz",
@ -367,9 +378,9 @@
} }
}, },
"node_modules/@codex-storage/sdk-js": { "node_modules/@codex-storage/sdk-js": {
"version": "0.0.7", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/@codex-storage/sdk-js/-/sdk-js-0.0.7.tgz", "resolved": "https://registry.npmjs.org/@codex-storage/sdk-js/-/sdk-js-0.0.8.tgz",
"integrity": "sha512-Ffp0hDxGxDv+PwcWZheDGnP1l9CagKQZgYgXNznF9TtQGFPWr3LAyN6xrfzv+XwDSlL2SQrJuLM2z8DLcRt2PA==", "integrity": "sha512-NSHwDpWmRVHlCJHUDVr7FZ0HBxpyoFNXHTjPqOPeQDaGlN+5Yzf/9aBU+lmYVdvIk68BQIzlScMIisRf8IYw9A==",
"dependencies": { "dependencies": {
"valibot": "^0.32.0" "valibot": "^0.32.0"
}, },
@ -588,6 +599,21 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.48.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.0.tgz",
"integrity": "sha512-W5lhqPUVPqhtc/ySvZI5Q8X2ztBOUgZ8LbAFy0JQgrXZs2xaILrUcNO3rQjwbLPfGK13+rZsDa1FpG+tqYkT5w==",
"dev": true,
"dependencies": {
"playwright": "1.48.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.22.4", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz",
@ -1648,6 +1674,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "22.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"dev": true,
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.12", "version": "15.7.12",
"dev": true, "dev": true,
@ -2062,6 +2097,12 @@
"integrity": "sha512-tKYm5YHPU1djz0O+CGJ+oJIvimtsCcwR2Z9w7Skh08lUdyzXY5djods3q+z2JkWdb7tCcmM//eVavSRAiaPRNg==", "integrity": "sha512-tKYm5YHPU1djz0O+CGJ+oJIvimtsCcwR2Z9w7Skh08lUdyzXY5djods3q+z2JkWdb7tCcmM//eVavSRAiaPRNg==",
"dev": true "dev": true
}, },
"node_modules/buffer-builder": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz",
"integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==",
"dev": true
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"dev": true, "dev": true,
@ -2141,6 +2182,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorjs.io": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
"dev": true
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"dev": true, "dev": true,
@ -2201,6 +2248,18 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dotenv": {
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotted-map": { "node_modules/dotted-map": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/dotted-map/-/dotted-map-2.2.3.tgz", "resolved": "https://registry.npmjs.org/dotted-map/-/dotted-map-2.2.3.tgz",
@ -2210,6 +2269,20 @@
"proj4": "^2.6.1" "proj4": "^2.6.1"
} }
}, },
"node_modules/echarts": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.1.tgz",
"integrity": "sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"dev": true, "dev": true,
@ -2599,6 +2672,20 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"dev": true, "dev": true,
@ -2705,6 +2792,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immutable": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
"integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
"dev": true
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.0", "version": "3.3.0",
"dev": true, "dev": true,
@ -3095,6 +3188,36 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.48.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.0.tgz",
"integrity": "sha512-qPqFaMEHuY/ug8o0uteYJSRfMGFikhUysk8ZvAtfKmUK3kc/6oNl/y3EczF8OFGYIi/Ex2HspMfzYArk6+XQSA==",
"dev": true,
"dependencies": {
"playwright-core": "1.48.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.48.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.0.tgz",
"integrity": "sha512-RBvzjM9rdpP7UUFrQzRwR8L/xR4HyC1QXMzGYTbf1vjw25/ya9NRAVnXi/0fvFopjebvyPzsmoK58xxeEOaVvA==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.45", "version": "8.4.45",
"dev": true, "dev": true,
@ -3334,6 +3457,402 @@
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
}, },
"node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"dev": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/sass-embedded": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.79.4.tgz",
"integrity": "sha512-3AATrtStMgxYjkit02/Ix8vx/P7qderYG6DHjmehfk5jiw53OaWVScmcGJSwp/d77kAkxDQ+Y0r+79VynGmrkw==",
"dev": true,
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"buffer-builder": "^0.2.0",
"colorjs.io": "^0.5.0",
"immutable": "^4.0.0",
"rxjs": "^7.4.0",
"supports-color": "^8.1.1",
"varint": "^6.0.0"
},
"bin": {
"sass": "dist/bin/sass.js"
},
"engines": {
"node": ">=16.0.0"
},
"optionalDependencies": {
"sass-embedded-android-arm": "1.79.4",
"sass-embedded-android-arm64": "1.79.4",
"sass-embedded-android-ia32": "1.79.4",
"sass-embedded-android-riscv64": "1.79.4",
"sass-embedded-android-x64": "1.79.4",
"sass-embedded-darwin-arm64": "1.79.4",
"sass-embedded-darwin-x64": "1.79.4",
"sass-embedded-linux-arm": "1.79.4",
"sass-embedded-linux-arm64": "1.79.4",
"sass-embedded-linux-ia32": "1.79.4",
"sass-embedded-linux-musl-arm": "1.79.4",
"sass-embedded-linux-musl-arm64": "1.79.4",
"sass-embedded-linux-musl-ia32": "1.79.4",
"sass-embedded-linux-musl-riscv64": "1.79.4",
"sass-embedded-linux-musl-x64": "1.79.4",
"sass-embedded-linux-riscv64": "1.79.4",
"sass-embedded-linux-x64": "1.79.4",
"sass-embedded-win32-arm64": "1.79.4",
"sass-embedded-win32-ia32": "1.79.4",
"sass-embedded-win32-x64": "1.79.4"
}
},
"node_modules/sass-embedded-android-arm": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.79.4.tgz",
"integrity": "sha512-YOVpDGDcwWUQvktpJhYo4zOkknDpdX6ALpaeHDTX6GBUvnZfx+Widh76v+QFUhiJQ/I/hndXg1jv/PKilOHRrw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-arm64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.79.4.tgz",
"integrity": "sha512-0JAZ8TtXYv9yI3Yasaq03xvo7DLJOmD+Exb30oJKxXcWTAV9TB0ZWKoIRsFxbCyPxyn7ouxkaCEXQtaTRKrmfw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-ia32": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.79.4.tgz",
"integrity": "sha512-IjO3RoyvNN84ZyfAR5s/a8TIdNPfClb7CLGrswB3BN/NElYIJUJMVHD6+Y8W9QwBIZ8DrK1IdLFSTV8nn82xMA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-riscv64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.79.4.tgz",
"integrity": "sha512-uOT8nXmKxSwuIdcqvElVWBFcm/+YcIvmwfoKbpuuSOSxUe9eqFzxo+fk7ILhynzf6FBlvRUH5DcjGj+sXtCc3w==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-x64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.79.4.tgz",
"integrity": "sha512-W2FQoj3Z2J2DirNs3xSBVvrhMuqLnsqvOPulxOkhL/074+faKOZZnPx2tZ5zsHbY97SonciiU0SV0mm98xI42w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-darwin-arm64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.79.4.tgz",
"integrity": "sha512-pcYtbN1VUAAcfgyHeX8ySndDWGjIvcq6rldduktPbGGuAlEWFDfnwjTbv0hS945ggdzZ6TFnaFlLEDr0SjKzBA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-darwin-x64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.79.4.tgz",
"integrity": "sha512-ir8CFTfc4JLx/qCP8LK1/3pWv35nRyAQkUK7lBIKM6hWzztt64gcno9rZIk4SpHr7Z/Bp1IYWWRS4ZT+4HmsbA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-arm": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.79.4.tgz",
"integrity": "sha512-H/XEE3rY7c+tY0qDaELjPjC6VheAhBo1tPJQ6UHoBEf8xrbT/RT3dWiIS8grp9Vk54RCn05BEB/+POaljvvKGA==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-arm64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.79.4.tgz",
"integrity": "sha512-XIVn2mCuA422SR2kmKjF6jhjMs1Vrt1DbZ/ktSp+eR0sU4ugu2htg45GajiUFSKKRj7Sc+cBdThq1zPPsDLf1w==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-ia32": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.79.4.tgz",
"integrity": "sha512-3nqZxV4nuUTb1ahLexVl4hsnx1KKwiGdHEf1xHWTZai6fYFMcwyNPrHySCQzFHqb5xiqSpPzzrKjuDhF6+guuQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-arm": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.79.4.tgz",
"integrity": "sha512-HnbU1DEiQdUayioNzxh2WlbTEgQRBPTgIIvof8J63QLmVItUqE7EkWYkSUy4RhO+8NsuN9wzGmGTzFBvTImU7g==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-arm64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.79.4.tgz",
"integrity": "sha512-C6qX06waPEfDgOHR8jXoYxl0EtIXOyBDyyonrLO3StRjWjGx7XMQj2hA/KXSsV+Hr71fBOsaViosqWXPzTbEiQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-ia32": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.79.4.tgz",
"integrity": "sha512-y5b0fdOPWyhj4c+mc88GvQiC5onRH1V0iNaWNjsiZ+L4hHje6T98nDLrCJn0fz5GQnXjyLCLZduMWbfV0QjHGg==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-riscv64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.79.4.tgz",
"integrity": "sha512-G2M5ADMV9SqnkwpM0S+UzDz7xR2njCOhofku/sDMZABzAjQQWTsAykKoGmzlT98fTw2HbNhb6u74umf2WLhCfw==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-x64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.79.4.tgz",
"integrity": "sha512-kQm8dCU3DXf7DtUGWYPiPs03KJYKvFeiZJHhSx993DCM8D2b0wCXWky0S0Z46gf1sEur0SN4Lvnt1WczTqxIBw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-riscv64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.79.4.tgz",
"integrity": "sha512-GaTI/mXYWYSzG5wxtM4H2cozLpATyh+4l+rO9FFKOL8e1sUOLAzTeRdU2nSBYCuRqsxRuTZIwCXhSz9Q3NRuNA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-x64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.79.4.tgz",
"integrity": "sha512-f9laGkqHgC01h99Qt4LsOV+OLMffjvUcTu14hYWqMS9QVX5a4ihMwpf1NoAtTUytb7cVF3rYY/NVGuXt6G3ppQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-win32-arm64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.79.4.tgz",
"integrity": "sha512-cidBvtaA2cJ6dNlwQEa8qak+ezypurzKs0h0QAHLH324+j/6Jum7LCnQhZRPYJBFjHl+WYd7KwzPnJ2X5USWnQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-win32-ia32": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.79.4.tgz",
"integrity": "sha512-hexdmNTIZGTKNTzlMcdvEXzYuxOJcY89zqgsf45aQ2YMy4y2M8dTOxRI/Vz7p4iRxVp1Jow6LCtaLHrNI2Ordg==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-win32-x64": {
"version": "1.79.4",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.79.4.tgz",
"integrity": "sha512-73yrpiWIbti6DkxhWURklkgSLYKfU9itDmvHxB+oYSb4vQveIApqTwSyTOuIUb/6Da/EsgEpdJ4Lbj4sLaMZWA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/sass-embedded/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"license": "MIT", "license": "MIT",
@ -3453,6 +3972,12 @@
"typescript": ">=4.2.0" "typescript": ">=4.2.0"
} }
}, },
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"dev": true
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"dev": true, "dev": true,
@ -3488,6 +4013,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true
},
"node_modules/unplugin": { "node_modules/unplugin": {
"version": "1.14.1", "version": "1.14.1",
"dev": true, "dev": true,
@ -3557,6 +4088,12 @@
"resolved": "https://registry.npmjs.org/valibot/-/valibot-0.32.0.tgz", "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.32.0.tgz",
"integrity": "sha512-FXBnJl4bNOmeg7lQv+jfvo/wADsRBN8e9C3r+O77Re3dEnDma8opp7p4hcIbF7XJJ30h/5SVohdjer17/sHOsQ==" "integrity": "sha512-FXBnJl4bNOmeg7lQv+jfvo/wADsRBN8e9C3r+O77Re3dEnDma8opp7p4hcIbF7XJJ30h/5SVohdjer17/sHOsQ=="
}, },
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"dev": true
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.7", "version": "5.4.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz",
@ -4094,6 +4631,19 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
},
"node_modules/zrender": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.0.tgz",
"integrity": "sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
} }
} }
} }

View File

@ -5,15 +5,16 @@
"type": "git", "type": "git",
"url": "https://github.com/codex-storage/codex-marketplace-ui" "url": "https://github.com/codex-storage/codex-marketplace-ui"
}, },
"version": "0.0.3", "version": "0.0.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host 127.0.0.1 --port 5173",
"compile": "tsc --noEmit", "compile": "tsc --noEmit",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview --host 127.0.0.1 --port 5173",
"format": "prettier --write ./src" "format": "prettier --write ./src",
"test": "npx playwright test"
}, },
"keywords": [ "keywords": [
"Codex", "Codex",
@ -23,12 +24,13 @@
"React" "React"
], ],
"dependencies": { "dependencies": {
"@codex-storage/marketplace-ui-components": "0.0.18", "@codex-storage/marketplace-ui-components": "^0.0.18",
"@codex-storage/sdk-js": "^0.0.7", "@codex-storage/sdk-js": "^0.0.8",
"@sentry/browser": "^8.32.0", "@sentry/browser": "^8.32.0",
"@sentry/react": "^8.31.0", "@sentry/react": "^8.31.0",
"@tanstack/react-query": "^5.51.15", "@tanstack/react-query": "^5.51.15",
"@tanstack/react-router": "^1.58.7", "@tanstack/react-router": "^1.58.7",
"echarts": "^5.5.1",
"dotted-map": "^2.2.3", "dotted-map": "^2.2.3",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.445.0", "lucide-react": "^0.445.0",
@ -36,17 +38,21 @@
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.48.0",
"@tanstack/router-devtools": "^1.58.7", "@tanstack/router-devtools": "^1.58.7",
"@tanstack/router-plugin": "^1.58.4", "@tanstack/router-plugin": "^1.58.4",
"@types/node": "^22.7.5",
"@types/react": "^18.3.8", "@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.7.0", "@typescript-eslint/eslint-plugin": "^8.7.0",
"@typescript-eslint/parser": "^8.7.0", "@typescript-eslint/parser": "^8.7.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"dotenv": "^16.4.5",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.12", "eslint-plugin-react-refresh": "^0.4.12",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"sass-embedded": "^1.79.4",
"typescript": "5.5.4", "typescript": "5.5.4",
"vite": "^5.4.7" "vite": "^5.4.7"
}, },

83
playwright.config.ts Normal file
View File

@ -0,0 +1,83 @@
import { defineConfig, devices } from '@playwright/test';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import dotenv from 'dotenv';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
dotenv.config({ path: path.resolve(__dirname, '.env.ci') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://127.0.0.1:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run preview',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
},
});

View File

@ -0,0 +1,8 @@
.availabilityTable-chevron {
cursor: pointer;
transition: transform 0.35s;
}
.availabilityTable-chevron--open {
transform: rotate(180deg);
}

View File

@ -1,22 +1,33 @@
import { Cell, Row, Table } from "@codex-storage/marketplace-ui-components"; import { Cell, Row, Table } from "@codex-storage/marketplace-ui-components";
import { TruncateCell } from "../TruncateCell/TruncateCell";
import { PrettyBytes } from "../../utils/bytes"; import { PrettyBytes } from "../../utils/bytes";
import { AvailabilityActionsCell } from "./AvailabilityActionsCell"; import { AvailabilityActionsCell } from "./AvailabilityActionsCell";
import { CodexAvailability } from "@codex-storage/sdk-js/async"; import { CodexAvailability, CodexNodeSpace } from "@codex-storage/sdk-js/async";
import { Times } from "../../utils/times"; import { Times } from "../../utils/times";
import { useState } from "react"; import { Fragment, useState } from "react";
import { AvailabilityReservations } from "./AvailabilityReservations"; import { AvailabilityReservations } from "./AvailabilityReservations";
import { AvailabilityIdCell } from "./AvailabilityIdCell";
import { ChevronDown } from "lucide-react";
import "./AvailabilitiesTable.css";
import { Arrays } from "../../utils/arrays";
import { AvailabilitySlotRow } from "./AvailabilitySlotRow";
import { classnames } from "../../utils/classnames";
import { AvailabilityWithSlots } from "./types";
import { AvailabilityDiskRow } from "./AvailabilityDiskRow";
type Props = { type Props = {
// onEdit: () => void; // onEdit: () => void;
availabilities: CodexAvailability[]; space: CodexNodeSpace;
availabilities: AvailabilityWithSlots[];
}; };
export function AvailabilitiesTable({ availabilities }: Props) { export function AvailabilitiesTable({ availabilities, space }: Props) {
const [availability, setAvailability] = useState<CodexAvailability | null>( const [availability, setAvailability] = useState<CodexAvailability | null>(
null null
); );
const [details, setDetails] = useState<string[]>([]);
const headers = [ const headers = [
"",
"id", "id",
"total size", "total size",
"duration", "duration",
@ -25,24 +36,52 @@ export function AvailabilitiesTable({ availabilities }: Props) {
"actions", "actions",
]; ];
const onReservationsShow = (a: CodexAvailability) => setAvailability(a);
const onReservationsClose = () => setAvailability(null); const onReservationsClose = () => setAvailability(null);
const rows = availabilities.map((a) => ( const rows = availabilities.map((a, index) => {
const showDetails = details.includes(a.id);
const onShowDetails = () => setDetails(Arrays.toggle(details, a.id));
const hasSlots = a.slots.length > 0;
return (
<Fragment key={a.id + a.duration}>
<Row <Row
cells={[ cells={[
<TruncateCell value={a.id} />, <Cell>
{hasSlots ? (
<ChevronDown
className={classnames(
["availabilityTable-chevron"],
["availabilityTable-chevron--open", showDetails]
)}
onClick={onShowDetails}></ChevronDown>
) : (
<span></span>
)}
</Cell>,
<AvailabilityIdCell value={a} index={index} />,
<Cell>{PrettyBytes(a.totalSize)}</Cell>, <Cell>{PrettyBytes(a.totalSize)}</Cell>,
<Cell>{Times.pretty(a.duration)}</Cell>, <Cell>{Times.pretty(a.duration)}</Cell>,
<Cell>{a.minPrice}</Cell>, <Cell>{a.minPrice.toString()}</Cell>,
<Cell>{a.maxCollateral}</Cell>, <Cell>{a.maxCollateral.toString()}</Cell>,
<AvailabilityActionsCell <AvailabilityActionsCell availability={a} />,
availability={a}
onReservationsShow={onReservationsShow}
/>,
]}></Row> ]}></Row>
));
{a.slots.map((slot) => (
<AvailabilitySlotRow
key={slot.id}
active={showDetails}
bytes={parseFloat(slot.size)}
id={slot.id}></AvailabilitySlotRow>
))}
</Fragment>
);
});
rows.unshift(
<AvailabilityDiskRow bytes={space.quotaMaxBytes}></AvailabilityDiskRow>
);
return ( return (
<> <>

View File

@ -1,4 +1,4 @@
import { StretchHorizontal } from "lucide-react"; import { Pencil } from "lucide-react";
import "./AvailabilityActionsCell.css"; import "./AvailabilityActionsCell.css";
import { CodexAvailability } from "@codex-storage/sdk-js/async"; import { CodexAvailability } from "@codex-storage/sdk-js/async";
import { Cell } from "@codex-storage/marketplace-ui-components"; import { Cell } from "@codex-storage/marketplace-ui-components";
@ -6,14 +6,10 @@ import { Cell } from "@codex-storage/marketplace-ui-components";
type Props = { type Props = {
availability: CodexAvailability; availability: CodexAvailability;
// onEdit: () => void; // onEdit: () => void;
onReservationsShow: (availability: CodexAvailability) => void;
}; };
export function AvailabilityActionsCell({ /* eslint-disable @typescript-eslint/no-unused-vars */
availability, export function AvailabilityActionsCell(_: Props) {
// onEdit,
onReservationsShow,
}: Props) {
// const onEditClick = async () => { // const onEditClick = async () => {
// const unit = availability.totalSize >= 1_000_000_000 ? "gb" : "mb"; // const unit = availability.totalSize >= 1_000_000_000 ? "gb" : "mb";
// const totalSize = // const totalSize =
@ -30,23 +26,11 @@ export function AvailabilityActionsCell({
// onEdit(); // onEdit();
// }; // };
const onReservationsClick = () => onReservationsShow(availability);
return ( return (
<Cell> <Cell>
<div className="availability-actions"> <div className="availability-actions">
{/* <a <a className="cell--action availability-action" title="Reservations">
className="cell--action availability-action" <Pencil width={"1.25rem"} />
title="Edit"
onClick={onEditClick}>
<Pen width={"1.25rem"} />
</a> */}
<a
className="cell--action availability-action"
title="Reservations"
onClick={onReservationsClick}>
<StretchHorizontal width={"1.25rem"} />
</a> </a>
</div> </div>
</Cell> </Cell>

View File

@ -0,0 +1,4 @@
import { createContext } from "react";
import { AvailabilityWithSlots } from "./types";
export const AvailabilityContext = createContext<AvailabilityWithSlots | null>(null);

View File

@ -0,0 +1,10 @@
.availabilityDiskRow {
border-bottom: 5px solid var(--codex-border-color);
background-color: var(--codex-background-light);
}
.availabilityDiskRow-cell-content {
display: flex;
align-items: center;
gap: 1rem;
}

View File

@ -0,0 +1,74 @@
import {
Cell,
Row,
SimpleText,
} from "@codex-storage/marketplace-ui-components";
import { PrettyBytes } from "../../utils/bytes";
import "./AvailabilityDiskRow.css";
import { classnames } from "../../utils/classnames";
type Props = {
bytes: number;
};
export function AvailabilityDiskRow({ bytes }: Props) {
return (
<Row
className={classnames(["availabilityDiskRow"])}
cells={[
<Cell className=" availabilityDiskRow-cell">
<span></span>
</Cell>,
<Cell colSpan={6} className={classnames([" availabilityDiskRow-cell"])}>
<div className={classnames(["availabilityDiskRow-cell-content"])}>
<HardDrive />
<div>
<div>
<b>Node</b>
</div>
<SimpleText size="small" variant="light">
{PrettyBytes(bytes)} allocated for the node
</SimpleText>
</div>
</div>
</Cell>,
]}></Row>
);
}
const HardDrive = () => (
<svg
width="30"
viewBox="0 0 60 80"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M55 0H5C2.23858 0 0 2.23858 0 5V75C0 77.7614 2.23858 80 5 80H55C57.7614 80 60 77.7614 60 75V5C60 2.23858 57.7614 0 55 0Z"
fill="#46484C"
/>
<path
d="M30 60C43.8071 60 55 48.8071 55 35C55 21.1929 43.8071 10 30 10C16.1929 10 5 21.1929 5 35C5 48.8071 16.1929 60 30 60Z"
fill="#9494D1"
/>
<path
d="M7.5 10C8.88071 10 10 8.88071 10 7.5C10 6.11929 8.88071 5 7.5 5C6.11929 5 5 6.11929 5 7.5C5 8.88071 6.11929 10 7.5 10Z"
fill="#95989D"
/>
<path
d="M52.5 10C53.8807 10 55 8.88071 55 7.5C55 6.11929 53.8807 5 52.5 5C51.1193 5 50 6.11929 50 7.5C50 8.88071 51.1193 10 52.5 10Z"
fill="#95989D"
/>
<path
d="M52.5 75C53.8807 75 55 73.8807 55 72.5C55 71.1193 53.8807 70 52.5 70C51.1193 70 50 71.1193 50 72.5C50 73.8807 51.1193 75 52.5 75Z"
fill="#95989D"
/>
<path
d="M30 40C32.7614 40 35 37.7614 35 35C35 32.2386 32.7614 30 30 30C27.2386 30 25 32.2386 25 35C25 37.7614 27.2386 40 30 40Z"
fill="#46484C"
/>
<path
d="M28.0697 41.4744C29.1749 42.165 29.4965 43.8048 28.8334 45.3439L19.8787 68.3287C19.7944 68.5884 19.6948 68.8452 19.5795 69.0978L19.4531 69.4169L19.4313 69.4035C19.3383 69.5843 19.2371 69.7626 19.1275 69.938C16.9697 73.3912 12.3729 74.4111 8.86014 72.2161C5.34741 70.0211 4.24902 65.4424 6.40681 61.9892C6.51642 61.8138 6.63231 61.6447 6.75405 61.4819L6.7324 61.4681L6.94941 61.2322C7.13484 61.0056 7.33205 60.7926 7.53964 60.5934L24.2571 42.4843C25.3497 41.2136 26.9645 40.7838 28.0697 41.4744Z"
fill="#CDCED0"
/>
</svg>
);

View File

@ -0,0 +1,186 @@
import {
Stepper,
useStepperReducer,
Button,
Modal,
} from "@codex-storage/marketplace-ui-components";
import { useEffect, useRef, useState } from "react";
import { AvailabilityForm } from "./AvailabilityForm";
import { Pencil, Plus } from "lucide-react";
import { CodexNodeSpace } from "@codex-storage/sdk-js";
import { AvailabilityConfirm } from "./AvailabilityConfirmation";
import { WebStorage } from "../../utils/web-storage";
import { AvailabilityState } from "./types";
import { STEPPER_DURATION } from "../../utils/constants";
import { useAvailabilityMutation } from "./useAvailabilityMutation";
import { AvailabilitySuccess } from "./AvailabilitySuccess";
import { AvailabilityError } from "./AvailabilityError";
import "./AvailabilityEdit.css";
type Props = {
space: CodexNodeSpace;
hasLabel?: boolean;
className?: string;
};
const CONFIRM_STATE = 2;
const defaultAvailabilityData: AvailabilityState = {
totalSize: 1,
duration: 1,
minPrice: 0,
maxCollateral: 0,
totalSizeUnit: "gb",
durationUnit: "days",
};
export function AvailabilityEdit({
space,
className = "",
hasLabel = true,
}: Props) {
const steps = useRef(["Sale", "Confirmation", "Success"]);
const [availability, setAvailability] = useState<AvailabilityState>(
defaultAvailabilityData
);
const { state, dispatch } = useStepperReducer();
const { mutateAsync, error } = useAvailabilityMutation(dispatch, state);
const [availabilityId, setAvailabilityId] = useState<string | null>(null);
useEffect(() => {
Promise.all([
WebStorage.get<number>("availability-step"),
WebStorage.get<AvailabilityState>("availability"),
]).then(([s, a]) => {
if (s) {
dispatch({
type: "next",
step: s,
});
}
if (a) {
setAvailability(a);
}
});
}, [dispatch]);
// We use a custom event to not re render the sunburst component
useEffect(() => {
const onAvailabilityIdChange = (e: Event) => {
const custom = e as CustomEvent;
setAvailabilityId(custom.detail);
};
document.addEventListener(
"codexavailabilityid",
onAvailabilityIdChange,
false
);
return () =>
document.removeEventListener(
"codexavailabilityid",
onAvailabilityIdChange
);
}, []);
const components = [
AvailabilityForm,
AvailabilityConfirm,
error ? AvailabilityError : AvailabilitySuccess,
];
const onNextStep = async (step: number) => {
if (step === components.length) {
setAvailability(defaultAvailabilityData);
dispatch({
step: 0,
type: "next",
});
dispatch({
type: "close",
});
return;
}
WebStorage.set("availability-step", step);
if (step == CONFIRM_STATE) {
mutateAsync(availability);
} else {
dispatch({
step,
type: "next",
});
}
};
const onAvailabilityChange = (data: Partial<AvailabilityState>) => {
const val = { ...availability, ...data };
WebStorage.set("availability", val);
setAvailability(val);
};
const onOpen = () => {
if (availability.id) {
WebStorage.set("availability-step", 0);
WebStorage.set("availability", defaultAvailabilityData);
setAvailability(defaultAvailabilityData);
}
dispatch({
type: "open",
});
dispatch({
step: 0,
type: "next",
});
};
const onClose = () => dispatch({ type: "close" });
const Body = components[state.step] || (() => <span />);
const backLabel = state.step ? "Back" : "Close";
const nextLabel = state.step === steps.current.length - 1 ? "Finish" : "Next";
return (
<>
<Button
label={hasLabel ? "Sale" : ""}
Icon={!availabilityId ? Plus : Pencil}
onClick={onOpen}
variant="primary"
className={className}
/>
<Modal open={state.open} onClose={onClose} displayCloseButton={false}>
<Stepper
className="availabilityCreate"
titles={steps.current}
state={state}
dispatch={dispatch}
duration={STEPPER_DURATION}
onNextStep={onNextStep}
backLabel={backLabel}
nextLabel={nextLabel}>
<Body
dispatch={dispatch}
state={state}
onAvailabilityChange={onAvailabilityChange}
availability={availability}
space={space}
error={error}
/>
</Stepper>
</Modal>
</>
);
}

View File

@ -17,3 +17,15 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
.availabilityForm-itemInput-maxSize {
color: var(--codex-color-primary);
padding-right: 0.5rem;
font-size: 0.85rem;
cursor: pointer;
transition: 0.35s opacity;
}
.availabilityForm-itemInput-maxSize:hover {
opacity: 0.7;
}

View File

@ -1,10 +1,14 @@
import { Input, InputGroup } from "@codex-storage/marketplace-ui-components"; import { Input, InputGroup } from "@codex-storage/marketplace-ui-components";
import { ChangeEvent, useEffect } from "react"; import { ChangeEvent, useEffect, useState } from "react";
import "./AvailabilityForm.css"; import "./AvailabilityForm.css";
import { AvailabilityComponentProps } from "./types"; import { AvailabilityComponentProps } from "./types";
import { classnames } from "../../utils/classnames"; import { classnames } from "../../utils/classnames";
import { AvailabilitySpaceAllocation } from "./AvailabilitySpaceAllocation"; import { AvailabilitySpaceAllocation } from "./AvailabilitySpaceAllocation";
import { availabilityMax, isAvailabilityValid } from "./availability.domain"; import {
availabilityMax,
availabilityUnit,
isAvailabilityValid,
} from "./availability.domain";
export function AvailabilityForm({ export function AvailabilityForm({
dispatch, dispatch,
@ -12,6 +16,12 @@ export function AvailabilityForm({
availability, availability,
space, space,
}: AvailabilityComponentProps) { }: AvailabilityComponentProps) {
const [availabilityValue, setAvailabilityValue] = useState(
(
availability.totalSize / availabilityUnit(availability.totalSizeUnit)
).toFixed(2)
);
useEffect(() => { useEffect(() => {
const max = availabilityMax(space); const max = availabilityMax(space);
const isValid = isAvailabilityValid(availability, max); const isValid = isAvailabilityValid(availability, max);
@ -44,9 +54,12 @@ export function AvailabilityForm({
const onAvailablityChange = async (e: ChangeEvent<HTMLInputElement>) => { const onAvailablityChange = async (e: ChangeEvent<HTMLInputElement>) => {
const element = e.currentTarget; const element = e.currentTarget;
const v = element.value; const v = element.value;
const unit = availabilityUnit(availability.totalSizeUnit);
setAvailabilityValue(v);
onAvailabilityChange({ onAvailabilityChange({
[element.name]: v, [element.name]: parseFloat(v) * unit,
}); });
}; };
@ -54,13 +67,31 @@ export function AvailabilityForm({
const element = e.currentTarget; const element = e.currentTarget;
onAvailabilityChange({ onAvailabilityChange({
[element.name]: parseFloat(element.value), [element.name]:
element.name === "name" ? element.value : parseFloat(element.value),
}); });
}; };
const max = availabilityMax(space); // const domain = new AvailabilityDomain(space, availability);
const isValid = isAvailabilityValid(availability, max);
const onMaxSize = () => {
const available =
space.quotaMaxBytes - space.quotaReservedBytes - space.quotaUsedBytes;
const unit = availabilityUnit(availability.totalSizeUnit);
setAvailabilityValue((available / unit).toFixed(2));
onAvailabilityChange({
totalSize: available,
});
};
const available =
space.quotaMaxBytes - space.quotaReservedBytes - space.quotaUsedBytes;
const isValid = available >= availability.totalSize;
const unit = availabilityUnit(availability.totalSizeUnit);
const max = available / unit;
const helper = isValid const helper = isValid
? "Total size of sale's storage in bytes" ? "Total size of sale's storage in bytes"
: "The total size cannot exceed the space available."; : "The total size cannot exceed the space available.";
@ -84,13 +115,18 @@ export function AvailabilityForm({
max={max.toFixed(2)} max={max.toFixed(2)}
onChange={onAvailablityChange} onChange={onAvailablityChange}
onGroupChange={onTotalSizeUnitChange} onGroupChange={onTotalSizeUnitChange}
value={availability.totalSize.toString()} value={availabilityValue}
step={"0.01"} step={"0.01"}
group={[ group={[
["gb", "GB"], ["gb", "GB"],
["tb", "TB"], ["tb", "TB"],
]} ]}
groupValue={availability.totalSizeUnit} groupValue={availability.totalSizeUnit}
extra={
<a onClick={onMaxSize} className="availabilityForm-itemInput-maxSize">
Use max size
</a>
}
/> />
<div className="availabilityForm-item"> <div className="availabilityForm-item">
@ -143,6 +179,21 @@ export function AvailabilityForm({
/> />
</div> </div>
</div> </div>
<div className="availabilityForm-item">
<Input
id="name"
name="name"
type="string"
label="Nickname"
max={9}
helper="You can add a custom name to easily retrieve your sale."
inputClassName="availabilityForm-itemInput"
onChange={onInputChange}
value={availability.name?.toString()}
maxLength={9}
/>
</div>
</> </>
); );
} }

View File

@ -0,0 +1,5 @@
.availabilityIdCell {
display: flex;
align-items: center;
gap: 1rem;
}

View File

@ -0,0 +1,62 @@
import "./AvailabilityIdCell.css";
import { Strings } from "../../utils/strings";
import { Cell, SimpleText } from "@codex-storage/marketplace-ui-components";
import { PrettyBytes } from "../../utils/bytes";
import { availabilityColors } from "./availability.colors";
import { AvailabilityWithSlots } from "./types";
type Props = {
value: AvailabilityWithSlots;
index: number;
};
export function AvailabilityIdCell({ value, index }: Props) {
return (
<Cell>
<div className="availabilityIdCell" id={value.id}>
<Folder color={availabilityColors[index]} />
<div>
<div>
<b>{value.name || Strings.shortId(value.id)}</b>
</div>
<SimpleText size="small" variant="light">
{PrettyBytes(value.totalSize)} allocated for the availability
</SimpleText>
<div>
{/* <div>
<SimpleText size="small" variant="light">
{a.id}
</SimpleText>
</div> */}
<SimpleText size="small" variant="light">
Max collateral {value.maxCollateral} | Min price {value.minPrice}
</SimpleText>
</div>
</div>
</div>
</Cell>
);
}
const Folder = ({ color }: { color: string }) => (
<svg
width="30"
viewBox="0 0 65 60"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M60.9133 4H24.9133C24.9133 1.8 23.1133 0 20.9133 0H4.91333C2.71333 0 0.91333 1.8 0.91333 4V16C0.91333 18.2 2.71333 20 4.91333 20H60.9133C63.1133 20 64.9133 18.2 64.9133 16V8C64.9133 5.8 63.1133 4 60.9133 4Z"
fill={color}
/>
<path
d="M56.9133 8H8.91333C6.71333 8 4.91333 9.8 4.91333 12V16C4.91333 18.2 6.71333 20 8.91333 20H56.9133C59.1133 20 60.9133 18.2 60.9133 16V12C60.9133 9.8 59.1133 8 56.9133 8Z"
fill="white"
/>
<path
d="M60.9133 12H4.91333C2.71333 12 0.91333 13.8 0.91333 16V56C0.91333 58.2 2.71333 60 4.91333 60H60.9133C63.1133 60 64.9133 58.2 64.9133 56V16C64.9133 13.8 63.1133 12 60.9133 12Z"
fill={color}
/>
</svg>
);

View File

@ -18,7 +18,7 @@ type Props = {
open: boolean; open: boolean;
onClose: () => unknown; onClose: () => unknown;
}; };
// TODO remove this
export function AvailabilityReservations({ export function AvailabilityReservations({
availability, availability,
onClose, onClose,
@ -38,7 +38,7 @@ export function AvailabilityReservations({
error, error,
} = useQuery({ } = useQuery({
queryFn: () => queryFn: () =>
CodexSdk.marketplace CodexSdk.marketplace()
.reservations(availability!.id) .reservations(availability!.id)
.then((s) => Promises.rejectOnError(s)), .then((s) => Promises.rejectOnError(s)),
queryKey: ["reservations"], queryKey: ["reservations"],

View File

@ -2,7 +2,7 @@ import {
Stepper, Stepper,
useStepperReducer, useStepperReducer,
Button, Button,
Modal, Sheets,
} from "@codex-storage/marketplace-ui-components"; } from "@codex-storage/marketplace-ui-components";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AvailabilityForm } from "./AvailabilityForm"; import { AvailabilityForm } from "./AvailabilityForm";
@ -19,6 +19,8 @@ import "./AvailabilityCreate.css";
type Props = { type Props = {
space: CodexNodeSpace; space: CodexNodeSpace;
hasLabel?: boolean;
className?: string;
}; };
const CONFIRM_STATE = 2; const CONFIRM_STATE = 2;
@ -32,7 +34,11 @@ const defaultAvailabilityData: AvailabilityState = {
durationUnit: "days", durationUnit: "days",
}; };
export function AvailabilityCreate({ space }: Props) { export function AvailabilitySheetCreate({
space,
className = "",
hasLabel = true,
}: Props) {
const steps = useRef(["Sale", "Confirmation", "Success"]); const steps = useRef(["Sale", "Confirmation", "Success"]);
const [availability, setAvailability] = useState<AvailabilityState>( const [availability, setAvailability] = useState<AvailabilityState>(
defaultAvailabilityData defaultAvailabilityData
@ -126,9 +132,15 @@ export function AvailabilityCreate({ space }: Props) {
return ( return (
<> <>
<Button label="Sale" Icon={Plus} onClick={onOpen} variant="primary" /> <Button
label={hasLabel ? "Sale" : ""}
Icon={Plus}
onClick={onOpen}
variant="primary"
className={className}
/>
<Modal open={state.open} onClose={onClose} displayCloseButton={false}> <Sheets open={state.open} onClose={onClose}>
<Stepper <Stepper
className="availabilityCreate" className="availabilityCreate"
titles={steps.current} titles={steps.current}
@ -147,7 +159,7 @@ export function AvailabilityCreate({ space }: Props) {
error={error} error={error}
/> />
</Stepper> </Stepper>
</Modal> </Sheets>
</> </>
); );
} }

View File

@ -0,0 +1,35 @@
.availabilitySlotRow {
border-bottom: none;
}
.availabilitySlotRow--active {
border-bottom: 1px solid var(--codex-border-color);
}
.availabilitySlotRow-cell {
padding: 0;
}
.availabilitySlotRow--inactive {
display: none;
}
.availabilitySlotRow-cell-content {
display: flex;
align-items: center;
gap: 1rem;
height: 0;
overflow: hidden;
transition: height 0.35s;
will-change: height;
padding-left: 3rem;
padding-right: 1rem;
}
.availabilitySlotRow--active .availabilitySlotRow-cell-content {
height: 65px;
}
.availabilitySlotRow--closing .availabilitySlotRow-cell-content {
height: 0;
}

View File

@ -0,0 +1,86 @@
import {
Cell,
Row,
SimpleText,
} from "@codex-storage/marketplace-ui-components";
import { PrettyBytes } from "../../utils/bytes";
import "./AvailabilitySlotRow.css";
import { classnames } from "../../utils/classnames";
import { useEffect, useState } from "react";
type Props = {
bytes: number;
id: string;
active: boolean;
};
export function AvailabilitySlotRow({ bytes, active, id }: Props) {
const [className, setClassName] = useState("availabilitySlotRow--inactive");
useEffect(() => {
if (active) {
setClassName("availabilitySlotRow--opening");
setTimeout(() => {
setClassName("availabilitySlotRow--active");
}, 15);
} else {
setClassName("availabilitySlotRow--closing");
setTimeout(() => {
setClassName("availabilitySlotRow--inactive");
}, 350);
}
}, [active]);
return (
<Row
className={classnames(["availabilitySlotRow"], [className])}
cells={[
<Cell className="availabilitySlotRow-cell">
<span></span>
</Cell>,
<Cell
colSpan={6}
className={classnames(
["availabilitySlotRow-cell"],
["availabilitySlotRow-cell--main"]
)}>
<div className={classnames(["availabilitySlotRow-cell-content"])}>
<SlotIcon />
<div>
<div>
<b>Slot {id}</b>
</div>
<SimpleText size="small" variant="light">
{PrettyBytes(bytes)} allocated for the slot
</SimpleText>
</div>
</div>
</Cell>,
]}></Row>
);
}
const SlotIcon = () => (
<svg
width="30"
viewBox="0 0 65 64"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M60.9133 0H4.91333C2.71333 0 0.91333 1.8 0.91333 4V60C0.91333 62.2 2.71333 64 4.91333 64H60.9133C63.1133 64 64.9133 62.2 64.9133 60V4C64.9133 1.8 63.1133 0 60.9133 0Z"
fill="#B59B77"
/>
<path
d="M26.9133 0V22C26.9133 23.1 27.8133 24 28.9133 24H36.9133C38.0133 24 38.9133 23.1 38.9133 22V0H26.9133Z"
fill="#D5B98B"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M56.3133 44.6L50.3133 38.6C49.9133 38.2 49.5133 38 48.9133 38C48.3133 38 47.9133 38.2 47.5133 38.6L41.5133 44.6C41.1133 45 40.9133 45.4 40.9133 46C40.9133 47.1 41.8133 48 42.9133 48C43.5133 48 43.9133 47.8 44.3133 47.4L46.9133 44.8V54C46.9133 55.1 47.8133 56 48.9133 56C49.9133 56 50.9133 55.1 50.9133 54V44.8L53.5133 47.5C53.9133 47.8 54.3133 48 54.9133 48C56.0133 48 56.9133 47.1 56.9133 46C56.9133 45.4 56.7133 45 56.3133 44.6Z"
fill="#865F3B"
/>
</svg>
);

View File

@ -2,7 +2,6 @@ import { CodexNodeSpace } from "@codex-storage/sdk-js";
import { AvailabilityState } from "./types"; import { AvailabilityState } from "./types";
import { SpaceAllocation } from "@codex-storage/marketplace-ui-components"; import { SpaceAllocation } from "@codex-storage/marketplace-ui-components";
import "./AvailabilitySpaceAllocation.css"; import "./AvailabilitySpaceAllocation.css";
import { availabilityUnit } from "./availability.domain";
import { nodeSpaceAllocationColors } from "../NodeSpaceAllocation/nodeSpaceAllocation.domain"; import { nodeSpaceAllocationColors } from "../NodeSpaceAllocation/nodeSpaceAllocation.domain";
type Props = { type Props = {
@ -11,15 +10,15 @@ type Props = {
}; };
export function AvailabilitySpaceAllocation({ availability, space }: Props) { export function AvailabilitySpaceAllocation({ availability, space }: Props) {
const unit = availabilityUnit(availability.totalSizeUnit); const { quotaMaxBytes, quotaReservedBytes, quotaUsedBytes } = space;
const { quotaMaxBytes, quotaReservedBytes } = space;
const size = availability.totalSize * unit;
const isUpdating = !!availability.id; const isUpdating = !!availability.id;
const allocated = isUpdating ? quotaReservedBytes - size : quotaReservedBytes; const allocated = isUpdating
? quotaReservedBytes - availability.totalSize + quotaUsedBytes
: quotaReservedBytes + quotaUsedBytes;
const remaining = const remaining =
size > quotaMaxBytes - allocated availability.totalSize > quotaMaxBytes - allocated
? quotaMaxBytes - allocated ? quotaMaxBytes - allocated
: quotaMaxBytes - allocated - size; : quotaMaxBytes - allocated - availability.totalSize;
const spaceData = [ const spaceData = [
{ {
@ -29,7 +28,7 @@ export function AvailabilitySpaceAllocation({ availability, space }: Props) {
}, },
{ {
title: "New space allocation", title: "New space allocation",
size: size > remaining ? 0 : Math.trunc(size), size: Math.trunc(availability.totalSize),
color: nodeSpaceAllocationColors[1], color: nodeSpaceAllocationColors[1],
}, },
{ {

View File

@ -0,0 +1,5 @@
.activity-sunburst {
height: 600px;
width: 600px;
margin: auto;
}

View File

@ -0,0 +1,185 @@
import { CodexNodeSpace } from "@codex-storage/sdk-js";
import { Times } from "../../utils/times";
import { Strings } from "../../utils/strings";
import { PrettyBytes } from "../../utils/bytes";
import { useEffect, useRef, useState } from "react";
import { CallbackDataParams, ECBasicOption } from "echarts/types/dist/shared";
import * as echarts from "echarts";
import { availabilityColors } from "./availability.colors";
import { AvailabilityWithSlots } from "./types";
import "./AvailabilitySunburst.css";
type Props = {
availabilities: AvailabilityWithSlots[];
space: CodexNodeSpace;
};
export function AvailabilitySunburst({ availabilities, space }: Props) {
const div = useRef<HTMLDivElement>(null);
const chart = useRef<echarts.EChartsType | null>(null);
const [, setRefresher] = useState(Date.now());
useEffect(() => {
if (div.current && !chart.current) {
chart.current = echarts.init(div.current);
setRefresher(Date.now());
}
}, [chart, div]);
const data = availabilities.map((a, index) => {
return {
name: Strings.shortId(a.id),
value: a.totalSize,
itemStyle: {
color: availabilityColors[index],
borderColor: "var(--codex-background)",
},
tooltip: {
backgroundColor: "#333",
textStyle: {
color: "#fff",
},
color: "white",
formatter: (params: CallbackDataParams) => {
return (
params.marker +
a.id +
"<br/>" +
"Duration " +
Times.pretty(a.duration) +
"<br/>" +
"Max collateral " +
a.maxCollateral +
"<br/>" +
"Min price " +
a.minPrice +
"<br/>" +
"Size " +
PrettyBytes(a.totalSize)
);
},
},
children: a.slots.map((slot) => ({
name: "",
value: parseFloat(slot.size),
children: [],
itemStyle: {
color: availabilityColors[index],
borderColor: "var(--codex-background)",
},
tooltip: {
backgroundColor: "#333",
textStyle: {
color: "#fff",
},
formatter: (params: CallbackDataParams) => {
return (
params.marker +
"Slot " +
slot.id +
PrettyBytes(parseFloat(slot.size))
);
},
},
})),
};
});
const option: ECBasicOption = {
series: {
type: "sunburst",
data: [
...data,
{
name: "Space remaining",
value:
space.quotaMaxBytes -
space.quotaReservedBytes -
space.quotaUsedBytes,
children: [],
itemStyle: {
color: "#ccc",
borderColor: "var(--codex-background)",
},
tooltip: {
backgroundColor: "#333",
textStyle: {
color: "#fff",
},
formatter: (params: CallbackDataParams) => {
return (
params.marker +
" Space remaining " +
PrettyBytes(
space.quotaMaxBytes -
space.quotaReservedBytes -
space.quotaUsedBytes
)
);
},
},
},
],
itemStyle: {
// borderRadius: 7,
borderWidth: 1,
},
label: {
show: true,
},
levels: [
{},
{
r0: "35%",
r: "70%",
label: {
align: "right",
},
},
{
r0: "75%",
r: "85%",
itemStyle: {
shadowBlur: 80,
shadowColor: "#ccc",
},
label: {
position: "outside",
textShadowBlur: 5,
textShadowColor: "#333",
},
downplay: {
label: {
opacity: 1,
},
},
},
],
},
tooltip: {
// type: "item",
},
};
if (chart.current) {
chart.current.setOption(option);
chart.current.off("click");
chart.current.on("click", function (params) {
// console.info(params.componentIndex);
// console.info(params.dataIndex);
const index = params.dataIndex;
const detail =
params.dataIndex === 0 ? null : availabilities[index - 1].id;
document.dispatchEvent(
new CustomEvent("codexavailabilityid", {
detail,
})
);
});
}
return <div id="chart" ref={div} className="activity-sunburst"></div>;
}

View File

@ -2,6 +2,42 @@ import { CodexNodeSpace } from "@codex-storage/sdk-js";
import { GB, TB } from "../../utils/constants"; import { GB, TB } from "../../utils/constants";
import { AvailabilityState } from "./types"; import { AvailabilityState } from "./types";
export class AvailabilityDomain {
space: CodexNodeSpace
state: AvailabilityState
constructor(space: CodexNodeSpace, availability: AvailabilityState) {
this.space = space
this.state = availability
}
get unitInBytes() {
return this.state.totalSizeUnit === "gb" ? GB : TB;
}
get unit() {
return this.state.totalSizeUnit;
}
get sizeInUnit() {
return this.state.totalSize
}
get maxInBytes() {
return this.space.quotaMaxBytes - this.space.quotaReservedBytes - this.space.quotaUsedBytes
}
get maxInUnit() {
return this.maxInBytes / this.unitInBytes / this.unitInBytes
}
isValid() {
const size = this.state.totalSize * this.unitInBytes;
return size > 0 && size <= this.maxInBytes;
}
}
export const availabilityUnit = (unit: "gb" | "tb") => export const availabilityUnit = (unit: "gb" | "tb") =>
unit === "gb" ? GB : TB; unit === "gb" ? GB : TB;
@ -11,9 +47,6 @@ export const availabilityMax = (space: CodexNodeSpace) =>
export const isAvailabilityValid = ( export const isAvailabilityValid = (
availability: AvailabilityState, availability: AvailabilityState,
max: number max: number
) => { ) => availability.totalSize > 0 && availability.totalSize <= max;
const unit = availabilityUnit(availability.totalSizeUnit);
const size = parseFloat(availability.totalSize.toString()) * unit;
return size > 0 && size <= max;
};

View File

@ -2,7 +2,11 @@ import {
StepperAction, StepperAction,
StepperState, StepperState,
} from "@codex-storage/marketplace-ui-components"; } from "@codex-storage/marketplace-ui-components";
import { CodexNodeSpace } from "@codex-storage/sdk-js"; import {
CodexAvailability,
CodexNodeSpace,
CodexReservation,
} from "@codex-storage/sdk-js";
import { Dispatch } from "react"; import { Dispatch } from "react";
export type AvailabilityState = { export type AvailabilityState = {
@ -13,6 +17,7 @@ export type AvailabilityState = {
minPrice: number; minPrice: number;
maxCollateral: number; maxCollateral: number;
totalSizeUnit: "gb" | "tb"; totalSizeUnit: "gb" | "tb";
name?: string;
}; };
export type AvailabilityComponentProps = { export type AvailabilityComponentProps = {
@ -23,3 +28,8 @@ export type AvailabilityComponentProps = {
availability: AvailabilityState; availability: AvailabilityState;
error: Error | null; error: Error | null;
}; };
export type AvailabilityWithSlots = CodexAvailability & {
name: string;
slots: CodexReservation[];
};

View File

@ -1,5 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { GB, TB } from "../../utils/constants";
import { Promises } from "../../utils/promises"; import { Promises } from "../../utils/promises";
import { WebStorage } from "../../utils/web-storage"; import { WebStorage } from "../../utils/web-storage";
import { AvailabilityState } from "./types"; import { AvailabilityState } from "./types";
@ -10,6 +9,9 @@ import {
} from "@codex-storage/marketplace-ui-components"; } from "@codex-storage/marketplace-ui-components";
import { Times } from "../../utils/times"; import { Times } from "../../utils/times";
import { CodexSdk } from "../../sdk/codex"; import { CodexSdk } from "../../sdk/codex";
import { AvailabilityStorage } from "../../utils/availabilities-storage";
import { CodexAvailabilityCreateResponse } from "@codex-storage/sdk-js";
export function useAvailabilityMutation( export function useAvailabilityMutation(
dispatch: Dispatch<StepperAction>, dispatch: Dispatch<StepperAction>,
@ -19,42 +21,46 @@ export function useAvailabilityMutation(
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const { mutateAsync } = useMutation({ const { mutateAsync } = useMutation({
/* eslint-disable @typescript-eslint/no-unused-vars */
mutationFn: ({ mutationFn: ({
totalSize, totalSize,
totalSizeUnit, totalSizeUnit,
duration, duration,
durationUnit = "days", durationUnit = "days",
name,
...input ...input
}: AvailabilityState) => { }: AvailabilityState) => {
const unit = totalSizeUnit === "gb" ? GB : TB;
const marketplace = CodexSdk.marketplace;
const time = Times.toSeconds(duration, durationUnit); const time = Times.toSeconds(duration, durationUnit);
const fn: ( const fn: (
input: Omit<AvailabilityState, "totalSizeUnit" | "durationUnit"> input: Omit<AvailabilityState, "totalSizeUnit" | "durationUnit">
) => Promise<unknown> = input.id ) => Promise<"" | CodexAvailabilityCreateResponse> = input.id
? (input) => ? (input) =>
marketplace CodexSdk.marketplace()
.updateAvailability({ ...input, id: input.id || "" }) .updateAvailability({ ...input, id: input.id || "" })
.then((s) => Promises.rejectOnError(s)) .then((s) => Promises.rejectOnError(s))
: (input) => : (input) =>
marketplace CodexSdk.marketplace()
.createAvailability(input) .createAvailability(input)
.then((s) => Promises.rejectOnError(s)); .then((s) => Promises.rejectOnError(s));
return fn({ return fn({
...input, ...input,
duration: time, duration: time,
totalSize: Math.trunc(totalSize * unit), totalSize: Math.trunc(totalSize),
}); });
}, },
onSuccess: () => { onSuccess: (res, body) => {
queryClient.invalidateQueries({ queryKey: ["availabilities"] }); queryClient.invalidateQueries({ queryKey: ["availabilities"] });
queryClient.invalidateQueries({ queryKey: ["space"] }); queryClient.invalidateQueries({ queryKey: ["space"] });
WebStorage.delete("availability"); WebStorage.delete("availability");
WebStorage.delete("availability-step"); WebStorage.delete("availability-step");
if (typeof res === "object" && body.name) {
AvailabilityStorage.add(res.id, body.name)
}
setError(null); setError(null);
dispatch({ dispatch({

View File

@ -6,7 +6,10 @@ import { CircleX } from "lucide-react";
export function Debug() { export function Debug() {
const { data, isPending, isError, error } = useQuery({ const { data, isPending, isError, error } = useQuery({
queryFn: () => CodexSdk.debug.info().then((s) => Promises.rejectOnError(s)), queryFn: () =>
CodexSdk.debug()
.info()
.then((s) => Promises.rejectOnError(s)),
queryKey: ["debug"], queryKey: ["debug"],
// No need to retry because if the connection to the node // No need to retry because if the connection to the node
@ -20,6 +23,9 @@ export function Debug() {
// Refreshing when focus returns can be useful if a user comes back // Refreshing when focus returns can be useful if a user comes back
// to the UI after performing an operation in the terminal. // to the UI after performing an operation in the terminal.
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
// Throw the error to the error boundary
throwOnError: true,
}); });
if (isPending) { if (isPending) {

View File

@ -0,0 +1,13 @@
.download {
display: flex;
align-items: center;
gap: 0.5rem;
}
.download-inputContainer {
flex: 1;
}
.download-input {
width: 100%;
}

View File

@ -0,0 +1,29 @@
import { Button, Input } from "@codex-storage/marketplace-ui-components";
import "./Download.css";
import { ChangeEvent, useState } from "react";
import { CodexSdk } from "../../sdk/codex";
export function Download() {
const [cid, setCid] = useState("");
console.info(cid);
const onDownload = () => {
const url = CodexSdk.url() + "/api/codex/v1/data/";
window.open(url + cid, "_target");
};
const onCidChange = (e: ChangeEvent<HTMLInputElement>) =>
setCid(e.currentTarget.value);
return (
<div className="download">
<div className="download-inputContainer">
<Input
id="cid"
placeholder="CID"
inputClassName="download-input"
onChange={onCidChange}></Input>
</div>
<Button label="Download" onClick={onDownload}></Button>
</div>
);
}

View File

@ -15,7 +15,9 @@ export function LogLevel() {
const [level, setLevel] = useState<CodexLogLevel>("DEBUG"); const [level, setLevel] = useState<CodexLogLevel>("DEBUG");
const { mutateAsync, isPending } = useMutation({ const { mutateAsync, isPending } = useMutation({
mutationFn: (level: CodexLogLevel) => mutationFn: (level: CodexLogLevel) =>
CodexSdk.debug.setLogLevel(level).then((s) => Promises.rejectOnError(s)), CodexSdk.debug()
.setLogLevel(level)
.then((s) => Promises.rejectOnError(s)),
onSuccess: () => { onSuccess: () => {
setToast({ setToast({
message: "The log level has been updated successfully.", message: "The log level has been updated successfully.",

View File

@ -19,8 +19,11 @@ export function NodeIndicator() {
const { data, isError } = useQuery({ const { data, isError } = useQuery({
queryKey: ["spr"], queryKey: ["spr"],
queryFn: async () => queryFn: async () => {
CodexSdk.node.spr().then((data) => Promises.rejectOnError(data, report)), return CodexSdk.node()
.spr()
.then((data) => Promises.rejectOnError(data, report));
},
refetchInterval: 5000, refetchInterval: 5000,
// No need to retry because we defined a refetch interval // No need to retry because we defined a refetch interval

View File

@ -14,7 +14,10 @@ const defaultSpace = {
export function NodeSpaceAllocation() { export function NodeSpaceAllocation() {
const { data: space, isPending } = useQuery({ const { data: space, isPending } = useQuery({
queryFn: () => CodexSdk.data.space().then((s) => Promises.rejectOnError(s)), queryFn: () =>
CodexSdk.data()
.space()
.then((s) => Promises.rejectOnError(s)),
queryKey: ["space"], queryKey: ["space"],
initialData: defaultSpace, initialData: defaultSpace,

View File

@ -0,0 +1,5 @@
.peerCountry {
display: flex;
align-items: center;
gap: 1rem;
}

View File

@ -0,0 +1,73 @@
import { Cell } from "@codex-storage/marketplace-ui-components";
import { PeerPin } from "./types";
import { countriesCoordinates } from "./countries";
import { useQuery } from "@tanstack/react-query";
import "./PeerCountryCell.css";
export type Props = {
address: string;
onPinAdd: (pin: PeerPin) => void;
};
const getFlagEmoji = (countryCode: string) => {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
export function PeerCountryCell({ address, onPinAdd }: Props) {
const { data } = useQuery({
queryFn: () => {
const [ip] = address.split(":");
return fetch(import.meta.env.VITE_GEO_IP_URL + "/" + ip)
.then((res) => res.json())
.then((json) => {
const coordinate = countriesCoordinates.find(
(c) => c.iso === json.country
);
if (coordinate) {
onPinAdd({
lat: parseFloat(coordinate.lat),
lng: parseFloat(coordinate.lng),
});
}
return coordinate;
});
},
queryKey: [address],
// Enable only when the address exists
enabled: !!address,
// No need to retry because if the connection to the node
// is back again, all the queries will be invalidated.
retry: false,
// We can cache the data at Infinity because the relation between
// country and ip is fixed
staleTime: Infinity,
// Don't expect something new when coming back to the UI
refetchOnWindowFocus: false,
});
return (
<Cell>
<div className="peerCountry">
{data ? (
<>
<span> {!!data && getFlagEmoji(data.iso)}</span>
<span>{data?.name}</span>
</>
) : (
<span>{address}</span>
)}
</div>
</Cell>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
export type PeerPin = {
lat: number;
lng: number;
};

View File

@ -95,7 +95,7 @@ export function StorageRequestFileChooser({
onSuccess={onSuccess} onSuccess={onSuccess}
editable={false} editable={false}
onDeleteItem={onDelete} onDeleteItem={onDelete}
codexData={CodexSdk.data} codexData={CodexSdk.data()}
successMessage={"Success, the CID has been copied to the field on top."} successMessage={"Success, the CID has been copied to the field on top."}
/> />
</> </>

View File

@ -18,7 +18,7 @@ export function useStorageRequestMutation(
const { mutateAsync } = useMutation({ const { mutateAsync } = useMutation({
mutationFn: (input: CodexCreateStorageRequestInput) => mutationFn: (input: CodexCreateStorageRequestInput) =>
CodexSdk.marketplace CodexSdk.marketplace()
.createStorageRequest(input) .createStorageRequest(input)
.then((s) => Promises.rejectOnError(s)), .then((s) => Promises.rejectOnError(s)),
onSuccess: async () => { onSuccess: async () => {

View File

@ -5,3 +5,22 @@
.truncateCell .tooltip:hover:after { .truncateCell .tooltip:hover:after {
left: -33%; left: -33%;
} }
.truncateCell-point {
height: 0.5rem;
width: 3rem;
border-radius: var(--codex-border-radius);
display: inline-block;
}
.truncateCell--ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 150px;
}
.table-tbodyTr:hover .truncateCell--ellipsis {
white-space: unset;
word-break: break-all;
}

View File

@ -1,4 +1,4 @@
import { Cell, Tooltip } from "@codex-storage/marketplace-ui-components"; import { Cell } from "@codex-storage/marketplace-ui-components";
import "./TruncateCell.css"; import "./TruncateCell.css";
type Props = { type Props = {
@ -7,15 +7,15 @@ type Props = {
export function TruncateCell({ value }: Props) { export function TruncateCell({ value }: Props) {
if (value.length <= 10) { if (value.length <= 10) {
return <span>{value}</span>; return <span id={value}>{value}</span>;
} }
return ( return (
<Cell> <Cell>
<div className="truncateCell"> <div className="truncateCell" id={value}>
<Tooltip message={value}> <div className="truncateCell--ellipsis">
<span>{value.slice(0, 10) + "..."}</span> <span>{value}</span>
</Tooltip> </div>
</div> </div>
</Cell> </Cell>
); );

View File

@ -6,6 +6,7 @@
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
flex: 1;
} }
.welcome-disclaimer { .welcome-disclaimer {

View File

@ -1,4 +1,4 @@
import { Alert, SimpleText } from "@codex-storage/marketplace-ui-components"; import { SimpleText } from "@codex-storage/marketplace-ui-components";
import "./Welcome.css"; import "./Welcome.css";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
@ -14,13 +14,6 @@ export function Welcome() {
explore its features. Your feedback is invaluable as we continue to explore its features. Your feedback is invaluable as we continue to
improve! improve!
</SimpleText> </SimpleText>
<Alert
variant="warning"
title="Disclaimer"
className="welcome-disclaimer">
The website and the content herein is not intended for public use and
is for informational and demonstration purposes only.
</Alert>
</div> </div>
<Link to="/dashboard/help" className="welcome-link"> <Link to="/dashboard/help" className="welcome-link">

View File

@ -7,7 +7,9 @@ export function useData() {
const { data = { content: [] } satisfies CodexDataResponse } = const { data = { content: [] } satisfies CodexDataResponse } =
useQuery<CodexDataResponse>({ useQuery<CodexDataResponse>({
queryFn: () => queryFn: () =>
CodexSdk.data.cids().then((res) => Promises.rejectOnError(res)), CodexSdk.data()
.cids()
.then((res) => Promises.rejectOnError(res)),
queryKey: ["cids"], queryKey: ["cids"],
initialData: { content: [] } satisfies CodexDataResponse, initialData: { content: [] } satisfies CodexDataResponse,

View File

@ -15,7 +15,7 @@ import * as Sentry from "@sentry/react";
import { CodexSdk } from "./sdk/codex"; import { CodexSdk } from "./sdk/codex";
import { ErrorPlaceholder } from "./components/ErrorPlaceholder/ErrorPlaceholder.tsx"; import { ErrorPlaceholder } from "./components/ErrorPlaceholder/ErrorPlaceholder.tsx";
if (import.meta.env.PROD) { if (import.meta.env.PROD && !import.meta.env.CI) {
Sentry.init({ Sentry.init({
release: "codex-storage-marketplace-ui@" + import.meta.env.PACKAGE_VERSION, release: "codex-storage-marketplace-ui@" + import.meta.env.PACKAGE_VERSION,
dsn: "https://22d77c59a27b8d5efc07132188b505b9@o4507855852011520.ingest.de.sentry.io/4507866758512720", dsn: "https://22d77c59a27b8d5efc07132188b505b9@o4507855852011520.ingest.de.sentry.io/4507866758512720",

View File

@ -179,6 +179,6 @@ class CodexMarketplaceMock extends CodexMarketplace {
export const CodexSdk = { export const CodexSdk = {
...Sdk, ...Sdk,
marketplace: new CodexMarketplaceMock(import.meta.env.VITE_CODEX_API_URL), marketplace: () => new CodexMarketplaceMock(CodexSdk.url()),
data: new CodexDataMock(import.meta.env.VITE_CODEX_API_URL), data: () => new CodexDataMock(CodexSdk.url()),
}; };

View File

@ -17,6 +17,7 @@ import { Route as DashboardIndexImport } from './routes/dashboard/index'
import { Route as DashboardSettingsImport } from './routes/dashboard/settings' import { Route as DashboardSettingsImport } from './routes/dashboard/settings'
import { Route as DashboardRequestsImport } from './routes/dashboard/requests' import { Route as DashboardRequestsImport } from './routes/dashboard/requests'
import { Route as DashboardPurchasesImport } from './routes/dashboard/purchases' import { Route as DashboardPurchasesImport } from './routes/dashboard/purchases'
import { Route as DashboardPeersImport } from './routes/dashboard/peers'
import { Route as DashboardHelpImport } from './routes/dashboard/help' import { Route as DashboardHelpImport } from './routes/dashboard/help'
import { Route as DashboardFavoritesImport } from './routes/dashboard/favorites' import { Route as DashboardFavoritesImport } from './routes/dashboard/favorites'
import { Route as DashboardDisclaimerImport } from './routes/dashboard/disclaimer' import { Route as DashboardDisclaimerImport } from './routes/dashboard/disclaimer'
@ -55,6 +56,11 @@ const DashboardPurchasesRoute = DashboardPurchasesImport.update({
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardRoute,
} as any) } as any)
const DashboardPeersRoute = DashboardPeersImport.update({
path: '/peers',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardHelpRoute = DashboardHelpImport.update({ const DashboardHelpRoute = DashboardHelpImport.update({
path: '/help', path: '/help',
getParentRoute: () => DashboardRoute, getParentRoute: () => DashboardRoute,
@ -133,6 +139,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardHelpImport preLoaderRoute: typeof DashboardHelpImport
parentRoute: typeof DashboardImport parentRoute: typeof DashboardImport
} }
'/dashboard/peers': {
id: '/dashboard/peers'
path: '/peers'
fullPath: '/dashboard/peers'
preLoaderRoute: typeof DashboardPeersImport
parentRoute: typeof DashboardImport
}
'/dashboard/purchases': { '/dashboard/purchases': {
id: '/dashboard/purchases' id: '/dashboard/purchases'
path: '/purchases' path: '/purchases'
@ -172,6 +185,7 @@ interface DashboardRouteChildren {
DashboardDisclaimerRoute: typeof DashboardDisclaimerRoute DashboardDisclaimerRoute: typeof DashboardDisclaimerRoute
DashboardFavoritesRoute: typeof DashboardFavoritesRoute DashboardFavoritesRoute: typeof DashboardFavoritesRoute
DashboardHelpRoute: typeof DashboardHelpRoute DashboardHelpRoute: typeof DashboardHelpRoute
DashboardPeersRoute: typeof DashboardPeersRoute
DashboardPurchasesRoute: typeof DashboardPurchasesRoute DashboardPurchasesRoute: typeof DashboardPurchasesRoute
DashboardRequestsRoute: typeof DashboardRequestsRoute DashboardRequestsRoute: typeof DashboardRequestsRoute
DashboardSettingsRoute: typeof DashboardSettingsRoute DashboardSettingsRoute: typeof DashboardSettingsRoute
@ -184,6 +198,7 @@ const DashboardRouteChildren: DashboardRouteChildren = {
DashboardDisclaimerRoute: DashboardDisclaimerRoute, DashboardDisclaimerRoute: DashboardDisclaimerRoute,
DashboardFavoritesRoute: DashboardFavoritesRoute, DashboardFavoritesRoute: DashboardFavoritesRoute,
DashboardHelpRoute: DashboardHelpRoute, DashboardHelpRoute: DashboardHelpRoute,
DashboardPeersRoute: DashboardPeersRoute,
DashboardPurchasesRoute: DashboardPurchasesRoute, DashboardPurchasesRoute: DashboardPurchasesRoute,
DashboardRequestsRoute: DashboardRequestsRoute, DashboardRequestsRoute: DashboardRequestsRoute,
DashboardSettingsRoute: DashboardSettingsRoute, DashboardSettingsRoute: DashboardSettingsRoute,
@ -202,6 +217,7 @@ export interface FileRoutesByFullPath {
'/dashboard/disclaimer': typeof DashboardDisclaimerRoute '/dashboard/disclaimer': typeof DashboardDisclaimerRoute
'/dashboard/favorites': typeof DashboardFavoritesRoute '/dashboard/favorites': typeof DashboardFavoritesRoute
'/dashboard/help': typeof DashboardHelpRoute '/dashboard/help': typeof DashboardHelpRoute
'/dashboard/peers': typeof DashboardPeersRoute
'/dashboard/purchases': typeof DashboardPurchasesRoute '/dashboard/purchases': typeof DashboardPurchasesRoute
'/dashboard/requests': typeof DashboardRequestsRoute '/dashboard/requests': typeof DashboardRequestsRoute
'/dashboard/settings': typeof DashboardSettingsRoute '/dashboard/settings': typeof DashboardSettingsRoute
@ -215,6 +231,7 @@ export interface FileRoutesByTo {
'/dashboard/disclaimer': typeof DashboardDisclaimerRoute '/dashboard/disclaimer': typeof DashboardDisclaimerRoute
'/dashboard/favorites': typeof DashboardFavoritesRoute '/dashboard/favorites': typeof DashboardFavoritesRoute
'/dashboard/help': typeof DashboardHelpRoute '/dashboard/help': typeof DashboardHelpRoute
'/dashboard/peers': typeof DashboardPeersRoute
'/dashboard/purchases': typeof DashboardPurchasesRoute '/dashboard/purchases': typeof DashboardPurchasesRoute
'/dashboard/requests': typeof DashboardRequestsRoute '/dashboard/requests': typeof DashboardRequestsRoute
'/dashboard/settings': typeof DashboardSettingsRoute '/dashboard/settings': typeof DashboardSettingsRoute
@ -230,6 +247,7 @@ export interface FileRoutesById {
'/dashboard/disclaimer': typeof DashboardDisclaimerRoute '/dashboard/disclaimer': typeof DashboardDisclaimerRoute
'/dashboard/favorites': typeof DashboardFavoritesRoute '/dashboard/favorites': typeof DashboardFavoritesRoute
'/dashboard/help': typeof DashboardHelpRoute '/dashboard/help': typeof DashboardHelpRoute
'/dashboard/peers': typeof DashboardPeersRoute
'/dashboard/purchases': typeof DashboardPurchasesRoute '/dashboard/purchases': typeof DashboardPurchasesRoute
'/dashboard/requests': typeof DashboardRequestsRoute '/dashboard/requests': typeof DashboardRequestsRoute
'/dashboard/settings': typeof DashboardSettingsRoute '/dashboard/settings': typeof DashboardSettingsRoute
@ -246,6 +264,7 @@ export interface FileRouteTypes {
| '/dashboard/disclaimer' | '/dashboard/disclaimer'
| '/dashboard/favorites' | '/dashboard/favorites'
| '/dashboard/help' | '/dashboard/help'
| '/dashboard/peers'
| '/dashboard/purchases' | '/dashboard/purchases'
| '/dashboard/requests' | '/dashboard/requests'
| '/dashboard/settings' | '/dashboard/settings'
@ -258,6 +277,7 @@ export interface FileRouteTypes {
| '/dashboard/disclaimer' | '/dashboard/disclaimer'
| '/dashboard/favorites' | '/dashboard/favorites'
| '/dashboard/help' | '/dashboard/help'
| '/dashboard/peers'
| '/dashboard/purchases' | '/dashboard/purchases'
| '/dashboard/requests' | '/dashboard/requests'
| '/dashboard/settings' | '/dashboard/settings'
@ -271,6 +291,7 @@ export interface FileRouteTypes {
| '/dashboard/disclaimer' | '/dashboard/disclaimer'
| '/dashboard/favorites' | '/dashboard/favorites'
| '/dashboard/help' | '/dashboard/help'
| '/dashboard/peers'
| '/dashboard/purchases' | '/dashboard/purchases'
| '/dashboard/requests' | '/dashboard/requests'
| '/dashboard/settings' | '/dashboard/settings'
@ -315,6 +336,7 @@ export const routeTree = rootRoute
"/dashboard/disclaimer", "/dashboard/disclaimer",
"/dashboard/favorites", "/dashboard/favorites",
"/dashboard/help", "/dashboard/help",
"/dashboard/peers",
"/dashboard/purchases", "/dashboard/purchases",
"/dashboard/requests", "/dashboard/requests",
"/dashboard/settings", "/dashboard/settings",
@ -341,6 +363,10 @@ export const routeTree = rootRoute
"filePath": "dashboard/help.tsx", "filePath": "dashboard/help.tsx",
"parent": "/dashboard" "parent": "/dashboard"
}, },
"/dashboard/peers": {
"filePath": "dashboard/peers.tsx",
"parent": "/dashboard"
},
"/dashboard/purchases": { "/dashboard/purchases": {
"filePath": "dashboard/purchases.tsx", "filePath": "dashboard/purchases.tsx",
"parent": "/dashboard" "parent": "/dashboard"

View File

@ -5,6 +5,19 @@
gap: 0.75rem; gap: 0.75rem;
} }
.dashboard-download {
margin-top: 1rem;
}
.dashboard-welcome {
display: flex;
flex-direction: column;
}
.dashboard-alert {
margin-bottom: 0;
}
@media (min-width: 1000px) { @media (min-width: 1000px) {
.dashboard { .dashboard {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@ -12,6 +12,7 @@ import {
Settings, Settings,
HelpCircle, HelpCircle,
TriangleAlert, TriangleAlert,
Earth,
} from "lucide-react"; } from "lucide-react";
import { ICON_SIZE } from "../utils/constants"; import { ICON_SIZE } from "../utils/constants";
import { NodeIndicator } from "../components/NodeIndicator/NodeIndicator"; import { NodeIndicator } from "../components/NodeIndicator/NodeIndicator";
@ -87,6 +88,15 @@ const Layout = () => {
</Link> </Link>
), ),
}, },
{
type: "menu-item",
Component: (p: MenuItemComponentProps) => (
<Link to="/dashboard/peers" {...p}>
<Earth size={ICON_SIZE} />
Peers
</Link>
),
},
{ {
type: "menu-item", type: "menu-item",
Component: (p: MenuItemComponentProps) => ( Component: (p: MenuItemComponentProps) => (

View File

@ -14,89 +14,142 @@
display: block; display: block;
} }
.availabilities-create {
position: absolute;
margin: auto;
border-radius: 100%;
height: 6rem;
width: 6rem;
}
.availabilities-create .button-label {
display: none;
}
.availabilities-header {
position: relative;
display: flex;
place-items: center;
justify-content: center;
margin-bottom: 2rem;
}
.availabilities-content { .availabilities-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.availabilities-table { .nodeSpaceAllocation-bar {
order: 2; background-color: var(--codex-background-light);
padding: 0.25rem;
border-radius: var(--codex-border-radius);
} }
.availabilities-space { .availabilities-space-allocation .nodeSpaceAllocation-legendRow,
display: flex; .availabilities-space-allocation .nodeSpaceAllocation-barItem {
align-items: flex-start;
order: 1;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
}
.availabilities-space .nodeSpaceAllocation-legendRow,
.availabilities-space .nodeSpaceAllocation-barItem {
transition: opacity 0.35s; transition: opacity 0.35s;
opacity: 0.8; opacity: 0.3;
} }
.availabilities-table:has(.table-tbodyTr:first-child:hover) .availabilities-space-allocation .nodeSpaceAllocation-barItem:hover {
+ .availabilities-space
.nodeSpaceAllocation-quota-0,
.availabilities-table:has(.table-tbodyTr:first-child:hover)
+ .availabilities-space
.nodeSpaceAllocation-legendRow:first-child {
opacity: 1; opacity: 1;
} }
.availabilities-table:has(.table-tbodyTr:nth-child(2):hover) .availabilities-space-allocation {
+ .availabilities-space flex: 1;
.nodeSpaceAllocation-quota-1, }
.availabilities-table:has(.table-tbodyTr:nth-child(2):hover) /*
+ .availabilities-space // This isn't the best approach, but it will suffice for now.
.nodeSpaceAllocation-legendRow:nth-child(2) { // The issue is that there is no sibling index to create a generic rule.
opacity: 1; // Additionally, rerendering the components with React on hover feels like overkill.
// We are also uncertain about the number of availabilities that will be in the table,
// so this workaround is acceptable for the time being.
// @for $i from 1 through 30 {
// .availabilities-table:has(.table-tbodyTr:nth-child(#{$i}):hover)
// + .availabilities-space
// .nodeSpaceAllocation-barItem:nth-child(#{$i}),
// .availabilities-table:has(.table-tbodyTr:nth-child(#{$i}):hover)
// + .availabilities-space
// .nodeSpaceAllocation-legendRow:nth-child(#{$i}) {
// opacity: 1;
// }
// .availabilities-table:has(.table-tbodyTr:nth-child(#{$i}):hover)
// + .availabilities-space
// .nodeSpaceAllocation-barItem:nth-child(#{$i})::after {
// opacity: 1;
// z-index: 1;
// }
// .availabilities-table:has(
// ~ .availabilities-space
// .nodeSpaceAllocation-barItem:nth-child(#{$i}):hover
// )
// .table-tbodyTr:nth-child(#{$i}) {
// background-color: var(--codex-background-light);
// }
// }
*/
.plus {
border-radius: 50%;
width: 5rem;
height: 5rem;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
margin: auto;
z-index: 1;
} }
.availabilities-table:has(.table-tbodyTr:nth-child(3):hover) .plus .button-label {
+ .availabilities-space display: none;
.nodeSpaceAllocation-quota-2,
.availabilities-table:has(.table-tbodyTr:nth-child(3):hover)
+ .availabilities-space
.nodeSpaceAllocation-legendRow:nth-child(3) {
opacity: 1;
} }
.availabilities-table:has(.table-tbodyTr:nth-child(4):hover) .progress {
+ .availabilities-space border: 1px solid var(--codex-border-color);
.nodeSpaceAllocation-quota-3, height: 8px;
.availabilities-table:has(.table-tbodyTr:nth-child(4):hover) width: 200px;
+ .availabilities-space border-radius: var(--codex-border-radius);
.nodeSpaceAllocation-legendRow:nth-child(4) { background-color: var(--codex-background);
opacity: 1; display: flex;
} }
.availabilities-table:has(.table-tbodyTr:nth-child(5):hover) .progress-bar {
+ .availabilities-space height: 100%;
.nodeSpaceAllocation-quota-4, background-color: var(--codex-progress-bar);
.availabilities-table:has(.table-tbodyTr:nth-child(5):hover) display: inline-block;
+ .availabilities-space border-radius: var(--codex-border-radius);
.nodeSpaceAllocation-legendRow:nth-child(5) {
opacity: 1;
} }
.availabilities-table:has(.table-tbodyTr:nth-child(6):hover) .progress-container {
+ .availabilities-space text-align: right;
.nodeSpaceAllocation-quota-5, display: flex;
.availabilities-table:has(.table-tbodyTr:nth-child(6):hover) flex-direction: column;
+ .availabilities-space gap: 0.5rem;
.nodeSpaceAllocation-legendRow:nth-child(6) { align-items: center;
opacity: 1;
} }
.availabilities-table:has(.table-tbodyTr:nth-child(7):hover) .slot {
+ .availabilities-space background-color: transparent;
.nodeSpaceAllocation-quota-6, background-image: repeating-linear-gradient(
.availabilities-table:has(.table-tbodyTr:nth-child(7):hover) -45deg,
+ .availabilities-space transparent,
.nodeSpaceAllocation-legendRow:nth-child(7) { transparent 1rem,
opacity: 1; rgb(var(--codex-color-primary-rgb)) 1rem,
rgb(var(--codex-color-primary-rgb)) 1.5rem
);
background-size: 200% 200%;
animation: barberpole 10s linear infinite;
}
@keyframes barberpole {
100% {
background-position: 100% 100%;
}
} }

View File

@ -1,13 +1,23 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { ErrorBoundary } from "@sentry/react"; import { ErrorBoundary } from "@sentry/react";
import { ErrorPlaceholder } from "../../components/ErrorPlaceholder/ErrorPlaceholder"; import { ErrorPlaceholder } from "../../components/ErrorPlaceholder/ErrorPlaceholder";
import { Spinner } from "@codex-storage/marketplace-ui-components"; import {
SpaceAllocationItem,
Spinner,
} from "@codex-storage/marketplace-ui-components";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Promises } from "../../utils/promises"; import { Promises } from "../../utils/promises";
import { CodexSdk } from "../../sdk/codex"; import { CodexSdk } from "../../sdk/codex";
import "./availabilities.css"; import "./availabilities.css";
import { AvailabilitiesTable } from "../../components/Availability/AvailabilitiesTable"; import { AvailabilitiesTable } from "../../components/Availability/AvailabilitiesTable";
import { AvailabilityCreate } from "../../components/Availability/AvailabilityCreate"; import { AvailabilityEdit } from "../../components/Availability/AvailabilityEdit";
import { Strings } from "../../utils/strings";
import { PrettyBytes } from "../../utils/bytes";
import { AvailabilitySunburst } from "../../components/Availability/AvailabilitySunburst";
import { Errors } from "../../utils/errors";
import { availabilityColors } from "../../components/Availability/availability.colors";
import { AvailabilityStorage } from "../../utils/availabilities-storage";
import { AvailabilityWithSlots } from "../../components/Availability/types";
const defaultSpace = { const defaultSpace = {
quotaMaxBytes: 0, quotaMaxBytes: 0,
@ -19,15 +29,42 @@ const defaultSpace = {
export function Availabilities() { export function Availabilities() {
{ {
// Error will be catched in ErrorBounday // Error will be catched in ErrorBounday
const { data: availabilities = [], isPending } = useQuery({ const { data: availabilities = [], isPending } = useQuery<
AvailabilityWithSlots[]
>({
queryFn: () => queryFn: () =>
CodexSdk.marketplace CodexSdk.marketplace()
.availabilities() .availabilities()
.then((s) => Promises.rejectOnError(s)) .then((s) => Promises.rejectOnError(s))
.then((res) => res.sort((a, b) => b.totalSize - a.totalSize)), .then((res) => res.sort((a, b) => b.totalSize - a.totalSize))
.then((data) =>
Promise.all(
data.map((a) =>
CodexSdk.marketplace()
.reservations(a.id)
.then((res) => {
if (res.error) {
Errors.report(res);
return { ...a, slots: [] };
}
return { ...a, slots: res.data };
})
.then((data) =>
AvailabilityStorage.get(data.id).then((n) => ({
...data,
name: n || "",
}))
)
)
)
),
queryKey: ["availabilities"], queryKey: ["availabilities"],
initialData: [], initialData: [],
// .then((res) =>
// res.error ? res : { ...data, slots: res.data }
// )
// No need to retry because if the connection to the node // No need to retry because if the connection to the node
// is back again, all the queries will be invalidated. // is back again, all the queries will be invalidated.
retry: false, retry: false,
@ -47,7 +84,9 @@ export function Availabilities() {
// Error will be catched in ErrorBounday // Error will be catched in ErrorBounday
const { data: space = defaultSpace } = useQuery({ const { data: space = defaultSpace } = useQuery({
queryFn: () => queryFn: () =>
CodexSdk.data.space().then((s) => Promises.rejectOnError(s)), CodexSdk.data()
.space()
.then((s) => Promises.rejectOnError(s)),
queryKey: ["space"], queryKey: ["space"],
initialData: defaultSpace, initialData: defaultSpace,
@ -67,12 +106,22 @@ export function Availabilities() {
throwOnError: true, throwOnError: true,
}); });
// const allocation = availabilities const allocation: SpaceAllocationItem[] = availabilities.map(
// .map((a) => ({ (a, index) => ({
// title: Strings.shortId(a.id), title: Strings.shortId(a.id),
// size: a.totalSize, size: a.totalSize,
// })) tooltip: a.id + "\u000D\u000A" + PrettyBytes(a.totalSize),
// .slice(0, 6); color: availabilityColors[index],
})
);
allocation.push({
title: "Space remaining",
// TODO move this to domain
size:
space.quotaMaxBytes - space.quotaReservedBytes - space.quotaUsedBytes,
color: "transparent",
});
return ( return (
<div className="container"> <div className="container">
@ -82,20 +131,28 @@ export function Availabilities() {
<Spinner width="3rem" /> <Spinner width="3rem" />
</div> </div>
) : ( ) : (
<>
<div className="availabilities-header">
<AvailabilitySunburst
availabilities={availabilities}
space={space}></AvailabilitySunburst>
<AvailabilityEdit
space={space}
className="availabilities-create"
hasLabel={false}
/>
</div>
<div className="availabilities-table"> <div className="availabilities-table">
<AvailabilitiesTable <AvailabilitiesTable
space={space}
// onEdit={onOpen} // onEdit={onOpen}
availabilities={availabilities} availabilities={availabilities}
/> />
</div> </div>
</>
)} )}
<div className="availabilities-space">
<div>{/* <SpaceAllocation data={allocation} /> */}</div>
<div>
<AvailabilityCreate space={space} />
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,11 +1,12 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Files } from "../../components/Files/Files.tsx"; import { Files } from "../../components/Files/Files.tsx";
import { Card, Upload } from "@codex-storage/marketplace-ui-components"; import { Alert, Card, Upload } from "@codex-storage/marketplace-ui-components";
import { CodexSdk } from "../../sdk/codex"; import { CodexSdk } from "../../sdk/codex";
import { Welcome } from "../../components/Welcome/Welcome.tsx"; import { Welcome } from "../../components/Welcome/Welcome.tsx";
import { ErrorPlaceholder } from "../../components/ErrorPlaceholder/ErrorPlaceholder.tsx"; import { ErrorPlaceholder } from "../../components/ErrorPlaceholder/ErrorPlaceholder.tsx";
import { ErrorBoundary } from "@sentry/react"; import { ErrorBoundary } from "@sentry/react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Download } from "../../components/Download/Download.tsx";
export const Route = createFileRoute("/dashboard/")({ export const Route = createFileRoute("/dashboard/")({
component: About, component: About,
@ -21,6 +22,7 @@ function About() {
return ( return (
<> <>
<div className="dashboard"> <div className="dashboard">
<div>
<ErrorBoundary <ErrorBoundary
fallback={({ error }) => ( fallback={({ error }) => (
<ErrorPlaceholder <ErrorPlaceholder
@ -29,7 +31,11 @@ function About() {
/> />
)}> )}>
<Card title="Upload a file"> <Card title="Upload a file">
<Upload multiple codexData={CodexSdk.data} onSuccess={onSuccess} /> <Upload
multiple
codexData={CodexSdk.data()}
onSuccess={onSuccess}
/>
</Card> </Card>
</ErrorBoundary> </ErrorBoundary>
@ -40,7 +46,30 @@ function About() {
subtitle="Cannot retrieve the data." subtitle="Cannot retrieve the data."
/> />
)}> )}>
<Card title="Download a file" className="dashboard-download">
<Download></Download>
</Card>
</ErrorBoundary>
</div>
<ErrorBoundary
fallback={({ error }) => (
<ErrorPlaceholder
error={error}
subtitle="Cannot retrieve the data."
/>
)}>
<div className="dashboard-welcome">
<Welcome /> <Welcome />
<Alert
variant="warning"
title="Disclaimer"
className="welcome-disclaimer dashboard-alert">
The website and the content herein is not intended for public use
and is for informational and demonstration purposes only.
</Alert>
</div>
</ErrorBoundary> </ErrorBoundary>
</div> </div>

View File

@ -0,0 +1,41 @@
.peers-map {
max-width: 1000px;
width: 100%;
}
.peers-table {
margin-top: 1rem;
width: calc(100% - 4rem);
max-width: calc(1000px - 4rem);
}
.peers {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 4rem;
padding-left: 2rem;
padding-right: 2rem;
}
.peers circle[fill="#d6ff79"] {
/* fill: yellow; */
animation: dash 3s linear infinite;
stroke: white;
stroke-width: 0.6px;
stroke-dasharray: 0.3;
}
@keyframes dash {
from {
stroke-dashoffset: 2;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes circleAn {
to {
/* stroke-dashoffset: 100px; */
}
}

View File

@ -0,0 +1,106 @@
import { Cell, Row, Table } from "@codex-storage/marketplace-ui-components";
import { createFileRoute } from "@tanstack/react-router";
import { getMapJSON } from "dotted-map";
import DottedMap from "dotted-map/without-countries";
import { Promises } from "../../utils/promises";
import { useQuery } from "@tanstack/react-query";
import { PeerCountryCell } from "../../components/Peers/PeerCountryCell";
import { useCallback, useState } from "react";
import { PeerPin } from "../../components/Peers/types";
import "./peers.css";
import { CodexSdk } from "../../sdk/codex";
// This function accepts the same arguments as DottedMap in the example above.
const mapJsonString = getMapJSON({ height: 60, grid: "diagonal" });
const Peers = () => {
const [pins, setPins] = useState<[PeerPin, number][]>([]);
const { data } = useQuery({
queryFn: () =>
CodexSdk.debug()
.info()
.then((s) => Promises.rejectOnError(s)),
queryKey: ["debug"],
// No need to retry because if the connection to the node
// is back again, all the queries will be invalidated.
retry: false,
// The client node should be local, so display the cache value while
// making a background request looks good.
staleTime: 0,
// Refreshing when focus returns can be useful if a user comes back
// to the UI after performing an operation in the terminal.
refetchOnWindowFocus: true,
// Throw the error to the error boundary
throwOnError: true,
});
const onPinAdd = useCallback((pin: PeerPin) => {
setPins((val) => {
const [, quantity = 0] =
val.find(([p]) => p.lat === pin.lat && p.lng == pin.lng) || [];
return [...val, [pin, quantity + 1]];
});
}, []);
// Its safe to re-create the map at each render, because of the
// pre-computation its super fast ⚡️
const map = new DottedMap({ map: JSON.parse(mapJsonString) });
pins.map(([pin, quantity]) =>
map.addPin({
lat: pin.lat,
lng: pin.lng,
svgOptions: { color: "#d6ff79", radius: 0.8 * quantity },
})
);
const svgMap = map.getSVG({
radius: 0.42,
color: "#423B38",
shape: "circle",
backgroundColor: "#020300",
});
const headers = ["Country", "PeerId", "Active"];
const rows =
(data?.table?.nodes || []).map((node) => (
<Row
cells={[
<PeerCountryCell
onPinAdd={onPinAdd}
address={node.address}></PeerCountryCell>,
<Cell>{node.peerId}</Cell>,
<Cell>
{node.seen ? (
<div className="networkIndicator-point networkIndicator-point--online"></div>
) : (
<div className="networkIndicator-point networkIndicator-point--offline"></div>
)}
</Cell>,
]}></Row>
)) || [];
return (
<div className="peers">
{/* <img
src={`data:image/svg+xml;utf8,${encodeURIComponent(svgMap)}`}
className="peers-map"
/> */}
<div
className="peers-map"
dangerouslySetInnerHTML={{ __html: svgMap }}></div>
<Table headers={headers} rows={rows} className="peers-table" />
</div>
);
};
export const Route = createFileRoute("/dashboard/peers")({
component: Peers,
});

View File

@ -20,7 +20,9 @@ import { ErrorBoundary } from "@sentry/react";
const Purchases = () => { const Purchases = () => {
const { data, isPending } = useQuery({ const { data, isPending } = useQuery({
queryFn: () => queryFn: () =>
CodexSdk.marketplace.purchases().then((s) => Promises.rejectOnError(s)), CodexSdk.marketplace()
.purchases()
.then((s) => Promises.rejectOnError(s)),
queryKey: ["purchases"], queryKey: ["purchases"],
// No need to retry because if the connection to the node // No need to retry because if the connection to the node
@ -75,9 +77,9 @@ const Purchases = () => {
/>, />,
<TruncateCell value={r.id} />, <TruncateCell value={r.id} />,
<Cell>{Times.pretty(duration)}</Cell>, <Cell>{Times.pretty(duration)}</Cell>,
<Cell>{ask.slots}</Cell>, <Cell>{ask.slots.toString()}</Cell>,
<Cell>{ask.reward + " CDX"}</Cell>, <Cell>{ask.reward + " CDX"}</Cell>,
<Cell>{pf}</Cell>, <Cell>{pf.toString()}</Cell>,
<CustomStateCellRender state={p.state} message={p.error} />, <CustomStateCellRender state={p.state} message={p.error} />,
]}></Row> ]}></Row>
); );

View File

@ -23,11 +23,19 @@ export const CodexSdk = {
return WebStorage.set("codex-node-url", url); return WebStorage.set("codex-node-url", url);
}, },
debug: client.debug, debug() {
return client.debug
},
data: client.data, data() {
return client.data
},
node: client.node, node() {
return client.node
},
marketplace: client.marketplace, marketplace() {
return client.marketplace
},
}; };

4
src/utils/arrays.ts Normal file
View File

@ -0,0 +1,4 @@
export const Arrays = {
toggle: <T>(arr: Array<T>, value: T) =>
arr.includes(value) ? arr.filter(i => i !== value) : [...arr, value]
}

View File

@ -0,0 +1,17 @@
import { createStore, del, get, set } from "idb-keyval";
const store = createStore("availabilities", "availabilities");
export const AvailabilityStorage = {
get(key: string) {
return get<string>(key, store);
},
delete(key: string) {
return del(key, store);
},
async add(key: string, value: string) {
return set(key, value, store);
},
};

19
src/utils/errors.ts Normal file
View File

@ -0,0 +1,19 @@
import * as Sentry from "@sentry/browser";
import { CodexError } from "@codex-storage/sdk-js";
export const Errors = {
report(safe: { error: true, data: CodexError }) {
if (safe.data.code === 502) {
// Ignore Gateway error
return
}
Sentry.captureException(safe.data, {
extra: {
code: safe.data.code,
errors: safe.data.errors,
sourceStack: safe.data.sourceStack,
},
});
}
}

View File

@ -1,36 +1,11 @@
import { SafeValue } from "@codex-storage/sdk-js"; import { SafeValue } from "@codex-storage/sdk-js";
import * as Sentry from "@sentry/browser"; import { Errors } from "./errors";
import { isCodexOnline } from "../components/NodeIndicator/NodeIndicator";
// It would be preferable to completely ignore the error
// when the node is not connected. However, during the
// initial load, we lack this information until the
// SPR response is completed. In the meantime, other
// requests may be initiated, so if the node is not
// connected, we should set the level to 'log'.
const getLogLevel = () => {
switch (isCodexOnline) {
case true:
return "error";
case null:
return "info";
case false:
return "log";
}
};
export const Promises = { export const Promises = {
rejectOnError: <T>(safe: SafeValue<T>, report = true) => { rejectOnError: <T>(safe: SafeValue<T>, report = true) => {
if (safe.error) { if (safe.error) {
if (report) { if (report) {
Sentry.captureException(safe.data, { Errors.report(safe)
extra: {
code: safe.data.code,
errors: safe.data.errors,
sourceStack: safe.data.sourceStack,
level: getLogLevel(),
},
});
} }
return Promise.reject(safe.data); return Promise.reject(safe.data);

1
src/vite-env.d.ts vendored
View File

@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
VITE_CODEX_API_URL: string; VITE_CODEX_API_URL: string;
VITE_GEO_IP_URL: string;
} }
interface ImportMeta { interface ImportMeta {