refactor(applaunchpad): remove extracted application code (#7352)
* refactor(applaunchpad): remove extracted application code * chore(frontend): clean up applaunchpad extraction leftovers
@@ -84,8 +84,6 @@ jobs:
|
||||
- '.github/workflows/frontends.yml'
|
||||
license:
|
||||
- 'frontend/providers/license/**'
|
||||
applaunchpad:
|
||||
- 'frontend/providers/applaunchpad/**'
|
||||
costcenter:
|
||||
- 'frontend/providers/costcenter/**'
|
||||
desktop:
|
||||
@@ -98,7 +96,6 @@ jobs:
|
||||
FORCE_ALL: ${{ inputs.force_all }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
LICENSE_CHANGED: ${{ steps.filter.outputs.license }}
|
||||
APPLAUNCHPAD_CHANGED: ${{ steps.filter.outputs.applaunchpad }}
|
||||
COSTCENTER_CHANGED: ${{ steps.filter.outputs.costcenter }}
|
||||
DESKTOP_CHANGED: ${{ steps.filter.outputs.desktop }}
|
||||
run: |
|
||||
@@ -111,14 +108,12 @@ jobs:
|
||||
"$EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
modules=(
|
||||
"providers/license"
|
||||
"providers/applaunchpad"
|
||||
"providers/costcenter"
|
||||
"desktop"
|
||||
)
|
||||
else
|
||||
# Only build changed modules
|
||||
[[ "$LICENSE_CHANGED" == "true" ]] && modules+=("providers/license")
|
||||
[[ "$APPLAUNCHPAD_CHANGED" == "true" ]] && modules+=("providers/applaunchpad")
|
||||
[[ "$COSTCENTER_CHANGED" == "true" ]] && modules+=("providers/costcenter")
|
||||
[[ "$DESKTOP_CHANGED" == "true" ]] && modules+=("desktop")
|
||||
fi
|
||||
|
||||
@@ -16,7 +16,6 @@ endif
|
||||
buildTargets := \
|
||||
desktop \
|
||||
providers/costcenter \
|
||||
providers/applaunchpad \
|
||||
providers/imagehub \
|
||||
providers/license
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ You can either run apps in workspace root or in specific app's directory.
|
||||
pnpm dev-desktop
|
||||
|
||||
# Run specific provider app
|
||||
pnpm dev-app # applaunchpad
|
||||
pnpm dev-cost # costcenter
|
||||
pnpm dev-template # template
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ export default staticContent;
|
||||
### Translation File Paths
|
||||
|
||||
- Desktop: [Desktop Translation Files](./desktop/public/locales)
|
||||
- App Launchpad: [App Launchpad Translation Files](./providers/applaunchpad/public/locales)
|
||||
- Cost Center: [Cost Center Translation Files](./providers/costcenter/public/locales)
|
||||
- Cron Jobs: [Cron Jobs Translation Files](./providers/cronjob/public/locales)
|
||||
- Cloud Server: [Cloud Server Translation Files](./providers/cloudserver/public/locales)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev-desktop": "pnpm -r --filter ./desktop run dev",
|
||||
"dev-app": "pnpm -r --filter ./providers/applaunchpad run dev",
|
||||
"dev-cost": "pnpm -r --filter ./providers/costcenter run dev",
|
||||
"dev-license": "pnpm -r --filter ./providers/license run dev",
|
||||
"gen:theme-typings": "pnpm chakra-cli tokens packages/ui/src/theme/theme.ts --out node_modules/.pnpm/node_modules/@chakra-ui/styled-system/dist/theming.types.d.ts",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
|
||||
.yalc/
|
||||
yalc.lock
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"react-hooks/rules-of-hooks": 0
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
/public/trainData/
|
||||
platform.json
|
||||
.yalc/
|
||||
yalc.lock
|
||||
data/*.local
|
||||
|
||||
data/*
|
||||
!data/config.example.yaml
|
||||
@@ -1,4 +0,0 @@
|
||||
dist
|
||||
.vscode
|
||||
**/.DS_Store
|
||||
node_modules
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
quoteProps: 'as-needed',
|
||||
jsxSingleQuote: false,
|
||||
trailingComma: 'none',
|
||||
bracketSpacing: true,
|
||||
jsxBracketSameLine: false,
|
||||
arrowParens: 'always',
|
||||
rangeStart: 0,
|
||||
rangeEnd: Infinity,
|
||||
requirePragma: false,
|
||||
insertPragma: false,
|
||||
proseWrap: 'preserve',
|
||||
htmlWhitespaceSensitivity: 'css',
|
||||
endOfLine: 'lf'
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": [
|
||||
"public/locales"
|
||||
],
|
||||
"i18n-ally.enabledParsers": [
|
||||
"json",
|
||||
"yaml",
|
||||
"js",
|
||||
"ts"
|
||||
],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.keepFulfilled": false,
|
||||
"i18n-ally.sourceLanguage": "en",
|
||||
"i18n-ally.displayLanguage": "en",
|
||||
"i18n-ally.pathMatcher": "{locale}/{namespaces}.json",
|
||||
"i18n-ally.extract.targetPickingStrategy": "most-similar-by-key"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.PHONY: all
|
||||
all: build
|
||||
|
||||
##@ General
|
||||
|
||||
# The help target prints out all targets with their descriptions organized
|
||||
# beneath their categories. The categories are represented by '##@' and the
|
||||
# target descriptions by '##'. The awk commands is responsible for reading the
|
||||
# entire set of makefiles included in this invocation, looking for lines of the
|
||||
# file as xyz: ## something, and then pretty-format the target and help. Then,
|
||||
# if there's a line with ##@ something, that gets pretty-printed as a category.
|
||||
# More info on the usage of ANSI control characters for terminal formatting:
|
||||
# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
|
||||
# More info on the awk command:
|
||||
# http://linuxcommand.org/lc3_adv_awk.php
|
||||
|
||||
.PHONY: help
|
||||
help: ## Display this help.
|
||||
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
|
||||
|
||||
##@ Build
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build desktop-frontend binary.
|
||||
pnpm run build
|
||||
|
||||
.PHONY: run
|
||||
run: ## Run a dev service from host.
|
||||
pnpm run start
|
||||
@@ -1,61 +0,0 @@
|
||||
# sealos app launchpad
|
||||
|
||||
## Preparation, refer to the README.md in the frontend directory
|
||||
|
||||
## project tree
|
||||
|
||||
```bash
|
||||
.
|
||||
├── data
|
||||
│ ├── form_slider_config.json // Optional configuration files
|
||||
├── deploy
|
||||
├── public
|
||||
│ ├── locales
|
||||
│ ├── favicon.ico
|
||||
│ └── logo.svg
|
||||
├── src
|
||||
│ ├── api
|
||||
│ ├── components // Components
|
||||
│ ├── constants
|
||||
│ ├── hooks
|
||||
│ ├── mock
|
||||
│ ├── pages // Pages, the path is the route
|
||||
│ │ ├── api // Server-side API
|
||||
│ │ ├── app
|
||||
│ │ │ ├── detail
|
||||
│ │ │ │ ├── components
|
||||
│ │ │ │ ├── index.module.scss
|
||||
│ │ │ │ └── index.tsx // Detail page
|
||||
│ │ │ └── edit
|
||||
│ │ │ ├── components
|
||||
│ │ │ ├── index.module.scss
|
||||
│ │ │ └── index.tsx // Create and edit page
|
||||
│ │ ├── apps
|
||||
│ │ │ ├── components
|
||||
│ │ │ ├── index.module.scss
|
||||
│ │ │ └── index.tsx // App list page
|
||||
│ │ ├── 404.tsx
|
||||
│ │ ├── _app.tsx
|
||||
│ │ └── _document.tsx
|
||||
│ ├── services
|
||||
│ │ ├── backend
|
||||
│ │ │ ├── auth.ts
|
||||
│ │ │ ├── kubernetes.ts
|
||||
│ │ │ └── response.ts
|
||||
│ │ ├── error.ts
|
||||
│ │ ├── kubernet.ts
|
||||
│ │ ├── monitorFetch.ts
|
||||
│ │ ├── request.ts
|
||||
│ │ └── streamFetch.ts
|
||||
│ ├── store
|
||||
│ ├── styles
|
||||
│ ├── types
|
||||
│ └── utils
|
||||
├── Makefile
|
||||
├── README.md
|
||||
├── next-env.d.ts
|
||||
├── next-i18next.config.js
|
||||
├── next.config.js
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
@@ -1,131 +0,0 @@
|
||||
import '@/styles/tailwind.css';
|
||||
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import NetworkConfigurationTable, {
|
||||
type NetworkConfigurationTableItem
|
||||
} from '@/components/app/detail/index/NetworkConfigurationTable';
|
||||
|
||||
vi.mock('@sealos/shadcn-ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
|
||||
}));
|
||||
|
||||
vi.mock('@/components/app/detail/index/ICPStatus', () => ({
|
||||
default: ({
|
||||
onRegistrationStatusChange
|
||||
}: {
|
||||
onRegistrationStatusChange?: (registered: boolean) => void;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
onRegistrationStatusChange?.(false);
|
||||
}, [onRegistrationStatusChange]);
|
||||
|
||||
return <span data-testid="icp-status">Domain not filed</span>;
|
||||
}
|
||||
}));
|
||||
|
||||
const publicAddress = 'https://very-long-public-domain-for-layout-regression.gzg.sealos.run';
|
||||
|
||||
const networks: NetworkConfigurationTableItem[] = [
|
||||
{
|
||||
inline: 'http://hello-world.ns-test.svc.cluster.local:8080',
|
||||
public: publicAddress,
|
||||
customDomain: '',
|
||||
showReadyStatus: true,
|
||||
port: 8080
|
||||
}
|
||||
];
|
||||
|
||||
const renderNetworkConfigurationTable = () => {
|
||||
return render(
|
||||
<div style={{ width: 360 }}>
|
||||
<NetworkConfigurationTable
|
||||
networks={networks}
|
||||
networkStatus={[{ ready: true, url: publicAddress }]}
|
||||
statusMap={{
|
||||
[publicAddress]: {
|
||||
ready: true,
|
||||
url: publicAddress
|
||||
}
|
||||
}}
|
||||
copyData={vi.fn()}
|
||||
t={(key) => key}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getLineTop = (element: Element) => Math.round(element.getBoundingClientRect().top);
|
||||
const expectOnSameLine = (left: Element, right: Element) => {
|
||||
expect(Math.abs(getLineTop(left) - getLineTop(right))).toBeLessThanOrEqual(2);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('NetworkConfigurationTable layout', () => {
|
||||
test('keeps the network table inside its parent in a narrow viewport', async () => {
|
||||
await page.viewport(390, 844);
|
||||
renderNetworkConfigurationTable();
|
||||
|
||||
const table = screen.getByRole('table');
|
||||
const tableHead = table.querySelector('thead');
|
||||
const tableBody = table.querySelector('tbody');
|
||||
const publicCell = within(table).getByText(publicAddress).closest('td');
|
||||
const accessibleTag = screen.getByText('Accessible');
|
||||
const renderedPublicAddress = screen.getByText(publicAddress);
|
||||
const publicContentRow = publicCell?.firstElementChild;
|
||||
const scrollContainer = table.parentElement as HTMLElement;
|
||||
|
||||
expect(tableHead).toBeTruthy();
|
||||
expect(tableBody).toBeTruthy();
|
||||
expect(publicCell).toBeTruthy();
|
||||
expect(publicContentRow).toBeTruthy();
|
||||
|
||||
expect(getComputedStyle(table).tableLayout).toBe('fixed');
|
||||
expect(Math.round(table.getBoundingClientRect().width)).toBeLessThanOrEqual(
|
||||
scrollContainer.clientWidth
|
||||
);
|
||||
expect(renderedPublicAddress.getBoundingClientRect().width).toBeLessThanOrEqual(220);
|
||||
expectOnSameLine(accessibleTag, renderedPublicAddress);
|
||||
});
|
||||
|
||||
test('shows the ICP tag as the only left status tag when domain is unregistered', async () => {
|
||||
await page.viewport(390, 844);
|
||||
render(
|
||||
<NetworkConfigurationTable
|
||||
networks={[
|
||||
{
|
||||
...networks[0],
|
||||
customDomain: 'example.com'
|
||||
}
|
||||
]}
|
||||
networkStatus={[{ ready: true, url: publicAddress }]}
|
||||
statusMap={{
|
||||
[publicAddress]: {
|
||||
ready: true,
|
||||
url: publicAddress
|
||||
}
|
||||
}}
|
||||
copyData={vi.fn()}
|
||||
t={(key) => key}
|
||||
/>
|
||||
);
|
||||
|
||||
const icpTag = screen.getByTestId('icp-status');
|
||||
const renderedPublicAddress = screen.getByText(publicAddress);
|
||||
|
||||
expect(screen.queryByText('Accessible')).toBeNull();
|
||||
expect(screen.queryByText('Ready')).toBeNull();
|
||||
expect(icpTag.getBoundingClientRect().left).toBeLessThan(
|
||||
renderedPublicAddress.getBoundingClientRect().left
|
||||
);
|
||||
expectOnSameLine(icpTag, renderedPublicAddress);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Quantity } from '@sealos/shared';
|
||||
import { getAppByName, getAppPodsByAppName } from '@/api/app';
|
||||
import { MOCK_APP_DETAIL, MOCK_PODS } from '@/mock/apps';
|
||||
import { GET } from '@/services/request';
|
||||
|
||||
vi.mock('@/services/request', () => ({
|
||||
GET: vi.fn(),
|
||||
POST: vi.fn(),
|
||||
DELETE: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@sealos/gtm', () => ({
|
||||
track: vi.fn()
|
||||
}));
|
||||
|
||||
const mockedGET = vi.mocked(GET);
|
||||
|
||||
describe('getAppByName', () => {
|
||||
it('hydrates Quantity fields from the API JSON payload', async () => {
|
||||
const payload = JSON.parse(JSON.stringify(MOCK_APP_DETAIL));
|
||||
payload.storeList = [
|
||||
{
|
||||
name: 'data',
|
||||
path: '/data',
|
||||
value: '2Gi'
|
||||
}
|
||||
];
|
||||
|
||||
mockedGET.mockResolvedValueOnce(payload);
|
||||
|
||||
const app = await getAppByName('hello-world');
|
||||
|
||||
expect(app.cpu).toBeInstanceOf(Quantity);
|
||||
expect(app.cpu.formatForDisplay({ format: 'DecimalSI' })).toBe('200m');
|
||||
expect(app.memory).toBeInstanceOf(Quantity);
|
||||
expect(app.memory.formatForDisplay({ format: 'BinarySI' })).toBe('256Mi');
|
||||
expect(app.storeList[0]?.value).toBeInstanceOf(Quantity);
|
||||
expect(app.storeList[0]?.value.formatForDisplay({ format: 'BinarySI' })).toBe('2Gi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppPodsByAppName', () => {
|
||||
it('hydrates pod Quantity fields from the API JSON payload', async () => {
|
||||
const payload = JSON.parse(JSON.stringify(MOCK_PODS));
|
||||
payload[0].cpu = '500m';
|
||||
payload[0].memory = '128Mi';
|
||||
|
||||
mockedGET.mockResolvedValueOnce(payload);
|
||||
|
||||
const pods = await getAppPodsByAppName('hello-world');
|
||||
|
||||
expect(pods[0]?.cpu).toBeInstanceOf(Quantity);
|
||||
expect(pods[0]?.cpu.formatForDisplay({ format: 'DecimalSI' })).toBe('500m');
|
||||
expect(pods[0]?.memory).toBeInstanceOf(Quantity);
|
||||
expect(pods[0]?.memory.formatForDisplay({ format: 'BinarySI' })).toBe('128Mi');
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import handler from '@/pages/api/checkReady';
|
||||
import { getK8s } from '@/services/backend/kubernetes';
|
||||
|
||||
vi.mock('@/services/backend/auth', () => ({
|
||||
authSession: vi.fn().mockResolvedValue('kubeconfig')
|
||||
}));
|
||||
|
||||
vi.mock('@/services/backend/kubernetes', () => ({
|
||||
getK8s: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
Config: () => ({
|
||||
cloud: {
|
||||
disableHttps: false,
|
||||
domain: 'sealos.run',
|
||||
httpPort: undefined,
|
||||
port: undefined,
|
||||
userDomains: []
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
const mockedGetK8s = vi.mocked(getK8s);
|
||||
const mockedFetch = vi.fn();
|
||||
|
||||
global.fetch = mockedFetch;
|
||||
|
||||
const createResponse = () => {
|
||||
const res = {
|
||||
json: vi.fn((payload) => payload)
|
||||
} as unknown as NextApiResponse & { json: ReturnType<typeof vi.fn> };
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
const callHandler = async () => {
|
||||
const req = {
|
||||
query: {
|
||||
appName: 'hello-world'
|
||||
},
|
||||
headers: {}
|
||||
} as unknown as NextApiRequest;
|
||||
const res = createResponse();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
return res.json.mock.calls[0][0];
|
||||
};
|
||||
|
||||
describe('/api/checkReady', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedGetK8s.mockResolvedValue({
|
||||
namespace: 'ns-test',
|
||||
k8sNetworkingApp: {
|
||||
listNamespacedIngress: vi.fn().mockResolvedValue({
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
annotations: {
|
||||
'nginx.ingress.kubernetes.io/backend-protocol': 'HTTP'
|
||||
}
|
||||
},
|
||||
spec: {
|
||||
rules: [
|
||||
{
|
||||
host: 'hello-world.sealos.run'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('marks a 503 no healthy upstream response as not ready', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
status: 503,
|
||||
headers: {
|
||||
get: vi.fn()
|
||||
},
|
||||
text: vi.fn().mockResolvedValue('no healthy upstream')
|
||||
});
|
||||
|
||||
const response = await callHandler();
|
||||
|
||||
expect(response.code).toBe(200);
|
||||
expect(response.data).toEqual([
|
||||
{
|
||||
ready: false,
|
||||
url: 'https://hello-world.sealos.run',
|
||||
error: 'Upstream not healthy'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,523 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import crypto from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Readable } from 'stream';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import handler, {
|
||||
formatChallengeHost,
|
||||
isPublicIp,
|
||||
sanitizeChallengeDomain
|
||||
} from '@/pages/api/platform/authDomainChallenge';
|
||||
import { queryA, queryAAAA } from '@/services/dns-resolver';
|
||||
import { getK8s } from '@/services/backend/kubernetes';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
|
||||
const TEST_DOMAIN_CHALLENGE_SECRET = vi.hoisted(() => ['test', 'secret'].join('-'));
|
||||
|
||||
vi.mock('nanoid', () => ({
|
||||
customAlphabet: () => () => 'abcdefghijklmnop'
|
||||
}));
|
||||
|
||||
vi.mock('@/services/dns-resolver', () => ({
|
||||
queryA: vi.fn(),
|
||||
queryAAAA: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@/services/backend/kubernetes', () => ({
|
||||
getK8s: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('http', () => ({
|
||||
default: {
|
||||
request: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('https', () => ({
|
||||
default: {
|
||||
request: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
Config: () => ({
|
||||
launchpad: {
|
||||
domainChallengeSecret: TEST_DOMAIN_CHALLENGE_SECRET
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
const mockedQueryA = vi.mocked(queryA);
|
||||
const mockedQueryAAAA = vi.mocked(queryAAAA);
|
||||
const mockedGetK8s = vi.mocked(getK8s);
|
||||
const mockedHttpRequest = vi.mocked(http.request);
|
||||
const mockedHttpsRequest = vi.mocked(https.request);
|
||||
|
||||
const createResponse = () => {
|
||||
const res = {
|
||||
json: vi.fn((payload) => payload)
|
||||
} as unknown as NextApiResponse & { json: ReturnType<typeof vi.fn> };
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
const callHandler = async (body: unknown, authorization = 'kubeconfig') => {
|
||||
const req = {
|
||||
body,
|
||||
headers: authorization ? { authorization } : {}
|
||||
} as unknown as NextApiRequest;
|
||||
const res = createResponse();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
return res.json.mock.calls[0][0];
|
||||
};
|
||||
|
||||
const createValidChallengeResponse = (host = 'example.com') => {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signatureData = `${host}:abcdefghijklmnop:${timestamp}:applaunchpad:false`;
|
||||
const signature = crypto
|
||||
.createHmac('sha256', TEST_DOMAIN_CHALLENGE_SECRET)
|
||||
.update(signatureData)
|
||||
.digest('hex');
|
||||
|
||||
return {
|
||||
host,
|
||||
token: 'abcdefghijklmnop',
|
||||
timestamp,
|
||||
service: 'applaunchpad',
|
||||
isProxy: false,
|
||||
signature
|
||||
};
|
||||
};
|
||||
|
||||
const createMockNodeResponse = (
|
||||
body: unknown,
|
||||
statusCode = 200,
|
||||
headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
) => {
|
||||
const payload = body === null ? '' : JSON.stringify(body);
|
||||
const res = Readable.from([Buffer.from(payload)]) as any;
|
||||
res.statusCode = statusCode;
|
||||
res.headers = headers;
|
||||
return res;
|
||||
};
|
||||
|
||||
const mockHttpResponse = (
|
||||
body: unknown,
|
||||
statusCode = 200,
|
||||
headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
) => {
|
||||
mockedHttpRequest.mockImplementationOnce((options: any, callback: any) => {
|
||||
const req = new EventEmitter() as any;
|
||||
req.end = vi.fn();
|
||||
|
||||
callback(createMockNodeResponse(body, statusCode, headers));
|
||||
|
||||
return req;
|
||||
});
|
||||
};
|
||||
|
||||
const mockHttpsResponse = (
|
||||
body: unknown,
|
||||
statusCode = 200,
|
||||
headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
) => {
|
||||
mockedHttpsRequest.mockImplementationOnce((options: any, callback: any) => {
|
||||
const req = new EventEmitter() as any;
|
||||
req.end = vi.fn();
|
||||
|
||||
callback(createMockNodeResponse(body, statusCode, headers));
|
||||
|
||||
return req;
|
||||
});
|
||||
};
|
||||
|
||||
describe('authDomainChallenge SSRF protections', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedGetK8s.mockResolvedValue({
|
||||
namespace: 'ns-test',
|
||||
k8sCore: {
|
||||
listNamespacedService: vi.fn().mockResolvedValue({})
|
||||
}
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('sanitizeChallengeDomain', () => {
|
||||
it('accepts normalized public hostnames only', () => {
|
||||
expect(sanitizeChallengeDomain(' Example.COM. ')).toBe('example.com');
|
||||
expect(sanitizeChallengeDomain('sub.example.co')).toBe('sub.example.co');
|
||||
});
|
||||
|
||||
it('rejects URL control characters and internal hostnames', () => {
|
||||
expect(sanitizeChallengeDomain('example.com:8080')).toBeNull();
|
||||
expect(sanitizeChallengeDomain('example.com/path#')).toBeNull();
|
||||
expect(sanitizeChallengeDomain('example.com?target=1')).toBeNull();
|
||||
expect(sanitizeChallengeDomain('user@example.com')).toBeNull();
|
||||
expect(sanitizeChallengeDomain('localhost')).toBeNull();
|
||||
expect(sanitizeChallengeDomain('account-service.account-system.svc')).toBeNull();
|
||||
expect(sanitizeChallengeDomain('kubernetes.default.svc.cluster.local')).toBeNull();
|
||||
expect(sanitizeChallengeDomain('10.0.0.1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicIp', () => {
|
||||
it('rejects private and reserved addresses', () => {
|
||||
expect(isPublicIp('10.0.0.1')).toBe(false);
|
||||
expect(isPublicIp('127.0.0.1')).toBe(false);
|
||||
expect(isPublicIp('169.254.169.254')).toBe(false);
|
||||
expect(isPublicIp('172.16.0.1')).toBe(false);
|
||||
expect(isPublicIp('192.168.1.1')).toBe(false);
|
||||
expect(isPublicIp('192.0.0.1')).toBe(false);
|
||||
expect(isPublicIp('192.0.2.1')).toBe(false);
|
||||
expect(isPublicIp('198.51.100.1')).toBe(false);
|
||||
expect(isPublicIp('203.0.113.1')).toBe(false);
|
||||
expect(isPublicIp('::1')).toBe(false);
|
||||
expect(isPublicIp('fc00::1')).toBe(false);
|
||||
expect(isPublicIp('fe80::1')).toBe(false);
|
||||
expect(isPublicIp('febf::1')).toBe(false);
|
||||
expect(isPublicIp('ff02::1')).toBe(false);
|
||||
expect(isPublicIp('::ffff:10.0.0.1')).toBe(false);
|
||||
expect(isPublicIp('::ffff:7f00:1')).toBe(false);
|
||||
expect(isPublicIp('::127.0.0.1')).toBe(false);
|
||||
expect(isPublicIp('::a00:1')).toBe(false);
|
||||
expect(isPublicIp('2002:0a00:0001::1')).toBe(false);
|
||||
expect(isPublicIp('2002:c0a8:0001::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows public addresses', () => {
|
||||
expect(isPublicIp('93.184.216.34')).toBe(true);
|
||||
expect(isPublicIp('192.0.1.1')).toBe(true);
|
||||
expect(isPublicIp('2001:4860:4860::8888')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('formats IPv6 hosts for URL authorities', () => {
|
||||
expect(formatChallengeHost('93.184.216.34')).toBe('93.184.216.34');
|
||||
expect(formatChallengeHost('2001:4860:4860::8888')).toBe('[2001:4860:4860::8888]');
|
||||
});
|
||||
|
||||
it('requires authentication before resolving or fetching', async () => {
|
||||
const response = await callHandler({ customDomain: 'example.com' }, '');
|
||||
|
||||
expect(response.code).toBe(401);
|
||||
expect(mockedQueryA).not.toHaveBeenCalled();
|
||||
expect(mockedHttpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns forbidden when the kubeconfig cannot access its namespace', async () => {
|
||||
mockedGetK8s.mockResolvedValueOnce({
|
||||
namespace: 'ns-test',
|
||||
k8sCore: {
|
||||
listNamespacedService: vi.fn().mockRejectedValue({
|
||||
body: {
|
||||
code: 403,
|
||||
message: 'forbidden'
|
||||
}
|
||||
})
|
||||
}
|
||||
} as any);
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' }, 'not-a-kubeconfig');
|
||||
|
||||
expect(response.code).toBe(403);
|
||||
expect(response.error.code).toBe('FORBIDDEN');
|
||||
expect(mockedQueryA).not.toHaveBeenCalled();
|
||||
expect(mockedHttpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report server-side authentication probe failures as unauthorized', async () => {
|
||||
mockedGetK8s.mockResolvedValueOnce({
|
||||
namespace: 'ns-test',
|
||||
k8sCore: {
|
||||
listNamespacedService: vi.fn().mockRejectedValue({
|
||||
body: {
|
||||
code: 500,
|
||||
message: 'apiserver unavailable'
|
||||
}
|
||||
})
|
||||
}
|
||||
} as any);
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(response.code).toBe(500);
|
||||
expect(response.error.code).toBe('INTERNAL_ERROR');
|
||||
expect(mockedQueryA).not.toHaveBeenCalled();
|
||||
expect(mockedHttpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects fragment path-control input before DNS resolution', async () => {
|
||||
const response = await callHandler({
|
||||
customDomain: 'desktop-frontend.sealos.svc:3000/api/platform/getCloudConfig#'
|
||||
});
|
||||
|
||||
expect(response.code).toBe(400);
|
||||
expect(response.error.code).toBe('INVALID_CUSTOM_DOMAIN');
|
||||
expect(mockedQueryA).not.toHaveBeenCalled();
|
||||
expect(mockedHttpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects domains resolving to private addresses', async () => {
|
||||
mockedQueryA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 60,
|
||||
data: '10.0.0.2'
|
||||
});
|
||||
mockedQueryAAAA.mockRejectedValueOnce(new Error('no AAAA'));
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(response.code).toBe(400);
|
||||
expect(response.error.code).toBe('PRIVATE_ADDRESS_NOT_ALLOWED');
|
||||
expect(mockedHttpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unsafe redirects during challenge fetch', async () => {
|
||||
mockedQueryA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 60,
|
||||
data: '93.184.216.34'
|
||||
});
|
||||
mockedQueryAAAA.mockRejectedValueOnce(new Error('no AAAA'));
|
||||
mockHttpResponse(null, 302, {
|
||||
location: 'http://account-service.account-system.svc:2333/swagger/doc.json'
|
||||
});
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(response.code).toBe(400);
|
||||
expect(response.error.code).toBe('CHALLENGE_REQUEST_FAILED');
|
||||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: '93.184.216.34',
|
||||
path: '/api/.well-known/applaunchpad-domain-challenge/abcdefghijklmnop',
|
||||
headers: expect.objectContaining({
|
||||
Host: 'example.com'
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockedHttpsRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows same-host HTTP to HTTPS challenge redirects', async () => {
|
||||
mockedQueryA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 60,
|
||||
data: '93.184.216.34'
|
||||
});
|
||||
mockedQueryAAAA.mockRejectedValueOnce(new Error('no AAAA'));
|
||||
mockHttpResponse(null, 301, {
|
||||
location: 'https://example.com/api/.well-known/applaunchpad-domain-challenge/abcdefghijklmnop'
|
||||
});
|
||||
mockHttpsResponse(createValidChallengeResponse());
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(response.code).toBe(200);
|
||||
expect(response.data.verified).toBe(true);
|
||||
expect(mockedHttpRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockedQueryA).toHaveBeenCalledTimes(1);
|
||||
expect(mockedQueryAAAA).toHaveBeenCalledTimes(1);
|
||||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: '93.184.216.34',
|
||||
path: '/api/.well-known/applaunchpad-domain-challenge/abcdefghijklmnop',
|
||||
headers: expect.objectContaining({
|
||||
Host: 'example.com'
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockedHttpsRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: '93.184.216.34',
|
||||
path: '/api/.well-known/applaunchpad-domain-challenge/abcdefghijklmnop',
|
||||
servername: 'example.com',
|
||||
headers: expect.objectContaining({
|
||||
Host: 'example.com'
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the initially resolved IP for same-host HTTPS redirects', async () => {
|
||||
mockedQueryA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 60,
|
||||
data: '93.184.216.34'
|
||||
});
|
||||
mockedQueryAAAA.mockRejectedValueOnce(new Error('no AAAA'));
|
||||
mockHttpResponse(null, 301, {
|
||||
location: 'https://example.com/api/.well-known/applaunchpad-domain-challenge/abcdefghijklmnop'
|
||||
});
|
||||
mockHttpsResponse(createValidChallengeResponse());
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(response.code).toBe(200);
|
||||
expect(mockedHttpRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockedQueryA).toHaveBeenCalledTimes(1);
|
||||
expect(mockedQueryAAAA).toHaveBeenCalledTimes(1);
|
||||
expect(mockedHttpsRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: '93.184.216.34',
|
||||
servername: 'example.com',
|
||||
headers: expect.objectContaining({
|
||||
Host: 'example.com'
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects redirects to a different host', async () => {
|
||||
mockedQueryA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 60,
|
||||
data: '93.184.216.34'
|
||||
});
|
||||
mockedQueryAAAA.mockRejectedValueOnce(new Error('no AAAA'));
|
||||
mockHttpResponse(null, 302, {
|
||||
location:
|
||||
'https://evil.example/api/.well-known/applaunchpad-domain-challenge/abcdefghijklmnop'
|
||||
});
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(response.code).toBe(400);
|
||||
expect(response.error.code).toBe('CHALLENGE_REQUEST_FAILED');
|
||||
expect(mockedHttpRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockedHttpsRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects redirects that change the challenge path', async () => {
|
||||
mockedQueryA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 60,
|
||||
data: '93.184.216.34'
|
||||
});
|
||||
mockedQueryAAAA.mockRejectedValueOnce(new Error('no AAAA'));
|
||||
mockHttpResponse(null, 302, {
|
||||
location: 'https://example.com/other-path'
|
||||
});
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(response.code).toBe(400);
|
||||
expect(response.error.code).toBe('CHALLENGE_REQUEST_FAILED');
|
||||
expect(mockedHttpRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockedHttpsRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reflect invalid challenge response bodies', async () => {
|
||||
mockedQueryA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 60,
|
||||
data: '93.184.216.34'
|
||||
});
|
||||
mockedQueryAAAA.mockRejectedValueOnce(new Error('no AAAA'));
|
||||
mockHttpResponse({
|
||||
internal: {
|
||||
token: 'should-not-leak'
|
||||
}
|
||||
});
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(response.code).toBe(400);
|
||||
expect(response.error.code).toBe('INVALID_CHALLENGE_RESPONSE');
|
||||
expect(response.error.response).toBeUndefined();
|
||||
expect(JSON.stringify(response)).not.toContain('should-not-leak');
|
||||
});
|
||||
|
||||
it('uses resolved IPv6 host for HTTP challenge targets', async () => {
|
||||
mockedQueryA.mockRejectedValueOnce(new Error('no A'));
|
||||
mockedQueryAAAA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'AAAA',
|
||||
ttl: 60,
|
||||
data: '2001:4860:4860::8888'
|
||||
});
|
||||
mockHttpResponse({
|
||||
internal: {
|
||||
token: 'should-not-leak'
|
||||
}
|
||||
});
|
||||
|
||||
await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: '2001:4860:4860::8888',
|
||||
path: '/api/.well-known/applaunchpad-domain-challenge/abcdefghijklmnop',
|
||||
headers: expect.objectContaining({
|
||||
Host: 'example.com'
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose computed signature material on signature failure', async () => {
|
||||
mockedQueryA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 60,
|
||||
data: '93.184.216.34'
|
||||
});
|
||||
mockedQueryAAAA.mockRejectedValueOnce(new Error('no AAAA'));
|
||||
|
||||
mockHttpResponse({
|
||||
host: 'attacker-controlled.example',
|
||||
token: 'abcdefghijklmnop',
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
service: 'applaunchpad',
|
||||
isProxy: false,
|
||||
signature: 'invalid-signature'
|
||||
});
|
||||
|
||||
const response = await callHandler({ customDomain: 'example.com' });
|
||||
|
||||
expect(response.code).toBe(400);
|
||||
expect(response.error.code).toBe('SIGNATURE_VERIFICATION_FAILED');
|
||||
expect(response.error.expected).toBeUndefined();
|
||||
expect(response.error.signatureData).toBeUndefined();
|
||||
expect(JSON.stringify(response)).not.toContain('attacker-controlled.example');
|
||||
});
|
||||
|
||||
it('verifies a valid public challenge response', async () => {
|
||||
mockedQueryA.mockResolvedValueOnce({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 60,
|
||||
data: '93.184.216.34'
|
||||
});
|
||||
mockedQueryAAAA.mockRejectedValueOnce(new Error('no AAAA'));
|
||||
|
||||
mockHttpResponse(createValidChallengeResponse());
|
||||
|
||||
const response = await callHandler({ customDomain: 'Example.COM.' });
|
||||
|
||||
expect(response.code).toBe(200);
|
||||
expect(response.data).toMatchObject({
|
||||
verified: true,
|
||||
domain: 'example.com',
|
||||
proxy: {
|
||||
isProxy: false
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AppEditType } from '@/types/app';
|
||||
import { hydrateLegacyAppForm, hydrateLegacyAppFormData } from '@/utils/hydrateLegacyAppForm';
|
||||
import {
|
||||
cpuMillicoresToQuantity,
|
||||
memoryMiToQuantity,
|
||||
storageGiToQuantity
|
||||
} from '@/utils/resourceQuantity';
|
||||
|
||||
const createApp = (): AppEditType =>
|
||||
({
|
||||
appName: 'demo',
|
||||
imageName: 'nginx:latest',
|
||||
runCMD: '',
|
||||
cmdParam: '',
|
||||
replicas: 1,
|
||||
cpu: cpuMillicoresToQuantity(100),
|
||||
memory: memoryMiToQuantity(128),
|
||||
networks: [],
|
||||
envs: [],
|
||||
hpa: {
|
||||
use: false,
|
||||
target: 'cpu',
|
||||
value: 50,
|
||||
minReplicas: 1,
|
||||
maxReplicas: 1
|
||||
},
|
||||
secret: {
|
||||
use: false,
|
||||
username: '',
|
||||
password: '',
|
||||
serverAddress: ''
|
||||
},
|
||||
configMapList: [],
|
||||
storeList: [],
|
||||
labels: {},
|
||||
volumes: [],
|
||||
volumeMounts: [],
|
||||
kind: 'deployment'
|
||||
} as AppEditType);
|
||||
|
||||
describe('hydrateLegacyAppForm', () => {
|
||||
it('converts partial URL formData resources to Quantity values', () => {
|
||||
const formData = hydrateLegacyAppFormData({
|
||||
cpu: 2000,
|
||||
memory: '4096'
|
||||
} as unknown as Partial<AppEditType>);
|
||||
|
||||
expect(formData.cpu?.toString()).toBe('2');
|
||||
expect(formData.memory?.toString()).toBe('4Gi');
|
||||
});
|
||||
|
||||
it('converts legacy numeric resources to Quantity values', () => {
|
||||
const app = hydrateLegacyAppForm({
|
||||
...createApp(),
|
||||
cpu: '500',
|
||||
memory: 1024,
|
||||
storeList: [
|
||||
{
|
||||
name: 'data',
|
||||
path: '/data',
|
||||
value: 2
|
||||
}
|
||||
]
|
||||
} as unknown as AppEditType);
|
||||
|
||||
expect(app.cpu.toString()).toBe('500m');
|
||||
expect(app.memory.toString()).toBe('1Gi');
|
||||
expect(app.storeList[0].value.toString()).toBe('2Gi');
|
||||
});
|
||||
|
||||
it('fills missing legacy resources with default Quantity values', () => {
|
||||
const app = hydrateLegacyAppForm({
|
||||
...createApp(),
|
||||
cpu: undefined,
|
||||
memory: undefined,
|
||||
configMapList: undefined,
|
||||
networks: undefined
|
||||
} as unknown as AppEditType);
|
||||
|
||||
expect(app.cpu.toString()).toBe('200m');
|
||||
expect(app.memory.toString()).toBe('256Mi');
|
||||
expect(app.configMapList).toEqual([]);
|
||||
expect(app.networks.length).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps Quantity resources unchanged', () => {
|
||||
const cpu = cpuMillicoresToQuantity(300);
|
||||
const memory = memoryMiToQuantity(512);
|
||||
const storage = storageGiToQuantity(1);
|
||||
const app = hydrateLegacyAppForm({
|
||||
...createApp(),
|
||||
cpu,
|
||||
memory,
|
||||
storeList: [
|
||||
{
|
||||
name: 'data',
|
||||
path: '/data',
|
||||
value: storage
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(app.cpu).toBe(cpu);
|
||||
expect(app.memory).toBe(memory);
|
||||
expect(app.storeList[0].value).toBe(storage);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import handler from '@/pages/api/v2alpha/docs';
|
||||
|
||||
const createResponse = () =>
|
||||
({
|
||||
setHeader: vi.fn(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
send: vi.fn()
|
||||
} as unknown as NextApiResponse & {
|
||||
setHeader: ReturnType<typeof vi.fn>;
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
});
|
||||
|
||||
describe('/api/v2alpha/docs', () => {
|
||||
it('returns a standalone Scalar docs page', async () => {
|
||||
const req = { method: 'GET' } as NextApiRequest;
|
||||
const res = createResponse();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send.mock.calls[0][0]).toContain('id="api-reference"');
|
||||
expect(res.send.mock.calls[0][0]).toContain('data-url="/api/v2alpha/openapi.json"');
|
||||
expect(res.send.mock.calls[0][0]).toContain('@scalar/api-reference@1.43.17');
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useForm, type UseFormReturn } from 'react-hook-form';
|
||||
import { NetworkSection } from '@/pages/app/edit/components/NetworkSection';
|
||||
import type { AppEditType } from '@/types/app';
|
||||
import { json2Ingress, json2Service, yamlString2Objects } from '@/utils/deployYaml2Json';
|
||||
|
||||
vi.mock('next-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock('next/dynamic', () => ({ default: () => () => null }));
|
||||
vi.mock('@/hooks/useClientAppConfig', () => ({
|
||||
useClientAppConfig: () => ({ domain: 'example.com', userDomains: [] })
|
||||
}));
|
||||
vi.mock('@sealos/shadcn-ui', () => ({
|
||||
Card: ({ children }: any) => <div>{children}</div>,
|
||||
CardContent: ({ children }: any) => <div>{children}</div>,
|
||||
CardDescription: ({ children }: any) => <div>{children}</div>,
|
||||
CardHeader: ({ children }: any) => <div>{children}</div>,
|
||||
CardTitle: ({ children }: any) => <div>{children}</div>,
|
||||
Input: React.forwardRef<HTMLInputElement, any>(function MockInput(props, ref) {
|
||||
return <input {...props} ref={ref} />;
|
||||
}),
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>
|
||||
}));
|
||||
vi.mock('@sealos/shadcn-ui/switch', () => ({
|
||||
Switch: ({ checked, onCheckedChange }: any) => (
|
||||
<button role="switch" aria-checked={checked} onClick={() => onCheckedChange(!checked)} />
|
||||
)
|
||||
}));
|
||||
vi.mock('@sealos/shadcn-ui/select', () => ({
|
||||
Select: ({ value, onValueChange, children }: any) => (
|
||||
<select value={value} onChange={(e) => onValueChange(e.target.value)}>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectContent: ({ children }: any) => <>{children}</>,
|
||||
SelectItem: ({ value, children }: any) => <option value={value}>{children}</option>,
|
||||
SelectTrigger: () => null,
|
||||
SelectValue: () => null
|
||||
}));
|
||||
vi.mock('@sealos/shadcn-ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
TooltipProvider: ({ children }: any) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: any) => <>{children}</>,
|
||||
TooltipContent: () => null
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Network = AppEditType['networks'][number];
|
||||
const network = (serviceName?: string): Network => ({
|
||||
serviceName,
|
||||
networkName: 'demo',
|
||||
portName: 'http',
|
||||
port: 8000,
|
||||
protocol: 'TCP',
|
||||
appProtocol: 'HTTP',
|
||||
openNodePort: false,
|
||||
openPublicDomain: true,
|
||||
publicDomain: 'demo-public',
|
||||
customDomain: '',
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
function setup(networks: Network[]) {
|
||||
let form: UseFormReturn<AppEditType>;
|
||||
function Harness() {
|
||||
form = useForm<AppEditType>({ defaultValues: { appName: 'demo', networks } });
|
||||
return <NetworkSection formHook={form} exceededQuotas={[]} handleOpenCostcenter={() => {}} />;
|
||||
}
|
||||
render(<Harness />);
|
||||
return () => form.getValues();
|
||||
}
|
||||
|
||||
const services = (data: AppEditType) => yamlString2Objects(json2Service(data)) as any[];
|
||||
const ingresses = (data: AppEditType) => yamlString2Objects(json2Ingress(data, [])) as any[];
|
||||
|
||||
describe('network Service identity', () => {
|
||||
it.each(['demo', undefined])(
|
||||
'preserves Service YAML through a public-access round trip (%s)',
|
||||
(name) => {
|
||||
const getData = setup([network(name)]);
|
||||
const beforeServices = services(getData());
|
||||
const beforeIngresses = ingresses(getData());
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
expect(services(getData())).toEqual(beforeServices);
|
||||
expect(ingresses(getData())).toEqual([]);
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
expect(services(getData())).toEqual(beforeServices);
|
||||
expect(ingresses(getData())).toEqual(beforeIngresses);
|
||||
expect(ingresses(getData())[0].spec.rules[0].http.paths[0].backend.service.name).toBe(
|
||||
beforeServices[0].metadata.name
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('generates matching Service and Ingress names when enabling a new private application', () => {
|
||||
const getData = setup([
|
||||
{ ...network(), openPublicDomain: false, networkName: '', publicDomain: '' }
|
||||
]);
|
||||
const before = services(getData());
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
expect(services(getData())).toEqual(before);
|
||||
expect(before[0].metadata.name).toMatch(/^demo-[a-z]{12}$/);
|
||||
expect(ingresses(getData())[0].spec.rules[0].http.paths[0].backend.service.name).toBe(
|
||||
before[0].metadata.name
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['WS', 'GRPC', 'HTTP'])('keeps the internal Service when selecting %s', (protocol) => {
|
||||
const getData = setup([network('demo')]);
|
||||
const before = services(getData());
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: protocol } });
|
||||
expect(services(getData())).toEqual(before);
|
||||
expect(getData().networks[0].appProtocol).toBe(protocol);
|
||||
});
|
||||
|
||||
it('does not reuse the ClusterIP name for a port moved into the NodePort group', () => {
|
||||
const getData = setup([network('demo'), { ...network('demo'), port: 9000, portName: 'other' }]);
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'TCP' } });
|
||||
const result = services(getData());
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].metadata.name).toBe('demo');
|
||||
expect(result[1].metadata.name).not.toBe('demo');
|
||||
expect(result[1].spec.type).toBe('NodePort');
|
||||
fireEvent.click(screen.getAllByRole('switch')[0]);
|
||||
expect(services(getData())).toHaveLength(1);
|
||||
expect(services(getData())[0].metadata.name).toBe('demo');
|
||||
});
|
||||
|
||||
it('does not reuse a shared NodePort name when moving one port to HTTP', () => {
|
||||
const tcp = {
|
||||
...network('demo-nodeport'),
|
||||
appProtocol: undefined,
|
||||
openNodePort: true,
|
||||
openPublicDomain: false,
|
||||
nodePort: 30080
|
||||
};
|
||||
const getData = setup([tcp, { ...tcp, port: 9000, portName: 'other', nodePort: 30090 }]);
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'HTTP' } });
|
||||
const result = services(getData());
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].metadata.name).not.toBe('demo-nodeport');
|
||||
expect(result[1].metadata.name).toBe('demo-nodeport');
|
||||
expect(result[0].spec.ports[0].nodePort).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
async function importCheckDomainICP() {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
globalThis.__APP_CONFIG__ = {
|
||||
launchpad: {
|
||||
checkIcpReg: {
|
||||
endpoint: 'cdn.aliyuncs.com',
|
||||
accessKeyID: 'test-access-key-id',
|
||||
accessKeySecret: 'test-access-key-secret'
|
||||
}
|
||||
}
|
||||
} as NonNullable<typeof globalThis.__APP_CONFIG__>;
|
||||
|
||||
return import('@/services/backend/acsCdnActions');
|
||||
}
|
||||
|
||||
describe('checkDomainICP', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns registered true for DomainIsRegistration responses', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({ Status: 'DomainIsRegistration' })));
|
||||
const { checkDomainICP } = await importCheckDomainICP();
|
||||
|
||||
await expect(checkDomainICP('example.com')).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
domain: 'example.com',
|
||||
icpRegistered: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns registered false for DomainNotRegistration responses', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({ Status: 'DomainNotRegistration' })));
|
||||
const { checkDomainICP } = await importCheckDomainICP();
|
||||
|
||||
await expect(checkDomainICP('example.com')).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
domain: 'example.com',
|
||||
icpRegistered: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('sends CheckCdnDomainICP with the requested domain', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({ Status: 'DomainIsRegistration' })));
|
||||
const { checkDomainICP } = await importCheckDomainICP();
|
||||
|
||||
await checkDomainICP('example.com');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const request = fetchMock.mock.calls[0]?.[0] as Request;
|
||||
expect(request.method).toBe('POST');
|
||||
expect(request.url).toBe('https://cdn.aliyuncs.com/');
|
||||
expect(request.headers.get('x-acs-action')).toBe('CheckCdnDomainICP');
|
||||
expect(request.headers.get('x-acs-version')).toBe('2018-05-10');
|
||||
await expect(request.json()).resolves.toEqual({ DomainName: 'example.com' });
|
||||
});
|
||||
|
||||
it('returns an error when ACS responds with a non-2xx error payload', async () => {
|
||||
const upstreamError = {
|
||||
Code: 'InvalidAccessKeyId.NotFound',
|
||||
Message: 'The Access Key ID provided does not exist in our records.'
|
||||
};
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(upstreamError), {
|
||||
status: 400,
|
||||
statusText: 'Bad Request'
|
||||
})
|
||||
);
|
||||
const { checkDomainICP } = await importCheckDomainICP();
|
||||
|
||||
await expect(checkDomainICP('example.com')).resolves.toEqual({
|
||||
success: false,
|
||||
cause: upstreamError
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when ACS returns an unexpected success payload', async () => {
|
||||
const unexpectedPayload = {
|
||||
RequestId: 'request-id',
|
||||
Code: 'SignatureDoesNotMatch',
|
||||
Message: 'Specified signature is not matched with our calculation.'
|
||||
};
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify(unexpectedPayload)));
|
||||
const { checkDomainICP } = await importCheckDomainICP();
|
||||
|
||||
await expect(checkDomainICP('example.com')).resolves.toEqual({
|
||||
success: false,
|
||||
cause: unexpectedPayload
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when ACS returns an unknown ICP status', async () => {
|
||||
const unexpectedPayload = { Status: 'UnknownStatus' };
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify(unexpectedPayload)));
|
||||
const { checkDomainICP } = await importCheckDomainICP();
|
||||
|
||||
await expect(checkDomainICP('example.com')).resolves.toEqual({
|
||||
success: false,
|
||||
cause: unexpectedPayload
|
||||
});
|
||||
});
|
||||
|
||||
it('returns text as the error cause for non-json ACS errors', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('Forbidden', { status: 403 }));
|
||||
const { checkDomainICP } = await importCheckDomainICP();
|
||||
|
||||
await expect(checkDomainICP('example.com')).resolves.toEqual({
|
||||
success: false,
|
||||
cause: 'Forbidden'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the thrown error when the ACS request rejects', async () => {
|
||||
const cause = new Error('network error');
|
||||
fetchMock.mockRejectedValue(cause);
|
||||
const { checkDomainICP } = await importCheckDomainICP();
|
||||
|
||||
await expect(checkDomainICP('example.com')).resolves.toEqual({
|
||||
success: false,
|
||||
cause
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,962 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
queryDns,
|
||||
getAuthoritativeNsFromRoot,
|
||||
getAuthoritativeNsFromLocal,
|
||||
extractStringAnswer,
|
||||
testCname,
|
||||
queryA,
|
||||
queryAAAA,
|
||||
resolveWithNs,
|
||||
extractNSServerMatch,
|
||||
ResolveError,
|
||||
ResolveErrorCode,
|
||||
type Nameserver
|
||||
} from '@/services/dns-resolver';
|
||||
|
||||
// Constants for testing (should match the constants in dns-resolver.ts)
|
||||
const MAX_NAMESERVERS = 3 as const;
|
||||
import type { StringAnswer } from 'dns-packet';
|
||||
import {
|
||||
dnsFixtures,
|
||||
findDnsResponse,
|
||||
findNsServerIp,
|
||||
findCnameRecord
|
||||
} from './dns-resolver.fixture';
|
||||
|
||||
vi.mock('node:dgram', () => {
|
||||
return {
|
||||
createSocket: vi.fn((socketType: string) => {
|
||||
const handlers: { [key: string]: Function[] } = {};
|
||||
const mockSocket: any = {
|
||||
send: vi.fn(
|
||||
(buffer: Uint8Array, port: number, address: string, callback: (err?: Error) => void) => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const dnsPacket = require('dns-packet');
|
||||
const query = dnsPacket.decode(Buffer.from(buffer));
|
||||
const question = query.questions?.[0];
|
||||
if (question) {
|
||||
const response = findDnsResponse(question.name, question.type, address);
|
||||
if (response) {
|
||||
const encoded = dnsPacket.encode(response);
|
||||
if (handlers['message']) {
|
||||
handlers['message'].forEach((handler) => handler(Buffer.from(encoded)));
|
||||
}
|
||||
} else {
|
||||
if (handlers['error']) {
|
||||
handlers['error'].forEach((handler) =>
|
||||
handler(
|
||||
new Error(
|
||||
`No fixture data for ${question.name} ${question.type} from ${address}`
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (handlers['error']) {
|
||||
handlers['error'].forEach((handler) => handler(new Error('Invalid query')));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (handlers['error']) {
|
||||
handlers['error'].forEach((handler) => handler(err));
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
callback();
|
||||
}
|
||||
),
|
||||
close: vi.fn(),
|
||||
on: vi.fn((event: string, handler: Function) => {
|
||||
if (!handlers[event]) {
|
||||
handlers[event] = [];
|
||||
}
|
||||
handlers[event].push(handler);
|
||||
}),
|
||||
removeListener: vi.fn(),
|
||||
removeAllListeners: vi.fn()
|
||||
};
|
||||
return mockSocket;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:dns/promises', async () => {
|
||||
return {
|
||||
resolve4: vi.fn(async (hostname: string) => {
|
||||
const nsIp = findNsServerIp(hostname);
|
||||
if (nsIp.ipv4) {
|
||||
return [nsIp.ipv4];
|
||||
}
|
||||
|
||||
throw new Error(`ENOTFOUND ${hostname}`);
|
||||
}),
|
||||
resolve6: vi.fn(async (hostname: string) => {
|
||||
const nsIp = findNsServerIp(hostname);
|
||||
if (nsIp.ipv6) {
|
||||
return [nsIp.ipv6];
|
||||
}
|
||||
|
||||
throw new Error(`ENOTFOUND ${hostname}`);
|
||||
}),
|
||||
getServers: vi.fn(() => {
|
||||
return ['8.8.8.8', '1.1.1.1'];
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('DNS Resolver', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('ResolveErrorCode', () => {
|
||||
it('should have all error codes as string literals', () => {
|
||||
const codes = Object.values(ResolveErrorCode) as string[];
|
||||
codes.forEach((code) => {
|
||||
expect(typeof code).toBe('string');
|
||||
expect(code.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique error codes', () => {
|
||||
const codes = Object.values(ResolveErrorCode);
|
||||
const uniqueCodes = new Set(codes);
|
||||
expect(uniqueCodes.size).toBe(codes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResolveError', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create error with required parameters only', () => {
|
||||
const error = new ResolveError(ResolveErrorCode.TIMEOUT, 'Test timeout error');
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(ResolveError);
|
||||
expect(error.name).toBe('ResolveError');
|
||||
expect(error.code).toBe(ResolveErrorCode.TIMEOUT);
|
||||
expect(error.message).toBe('Test timeout error');
|
||||
expect(error.domain).toBeUndefined();
|
||||
expect(error.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create error with optional domain and details', () => {
|
||||
const details = { rcode: 'SERVFAIL', targetType: 'A' };
|
||||
const error = new ResolveError(ResolveErrorCode.DNS_ERROR, 'DNS server error', {
|
||||
domain: 'example.com',
|
||||
details
|
||||
});
|
||||
|
||||
expect(error.code).toBe(ResolveErrorCode.DNS_ERROR);
|
||||
expect(error.domain).toBe('example.com');
|
||||
expect(error.details).toEqual(details);
|
||||
});
|
||||
|
||||
it('should handle undefined and empty options', () => {
|
||||
const error1 = new ResolveError(ResolveErrorCode.NO_RECORD, 'No record found', undefined);
|
||||
const error2 = new ResolveError(ResolveErrorCode.CNAME_LOOP, 'CNAME loop detected', {});
|
||||
|
||||
expect(error1.domain).toBeUndefined();
|
||||
expect(error1.details).toBeUndefined();
|
||||
expect(error2.domain).toBeUndefined();
|
||||
expect(error2.details).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error properties', () => {
|
||||
it('should preserve error message in stack trace', () => {
|
||||
const error = new ResolveError(ResolveErrorCode.DNS_ERROR, 'DNS Error');
|
||||
expect(error.stack).toContain('DNS Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractStringAnswer', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('should extract records of specified types', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 300,
|
||||
data: '93.184.216.34'
|
||||
},
|
||||
{
|
||||
name: 'example.com',
|
||||
type: 'AAAA',
|
||||
ttl: 300,
|
||||
data: '2606:2800:220:1:248:1893:25c8:1946'
|
||||
},
|
||||
{
|
||||
name: 'example.com',
|
||||
type: 'CNAME',
|
||||
ttl: 300,
|
||||
data: 'www.example.com'
|
||||
}
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, ['A', 'AAAA']);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: 300,
|
||||
data: '93.184.216.34'
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
name: 'example.com',
|
||||
type: 'AAAA',
|
||||
ttl: 300,
|
||||
data: '2606:2800:220:1:248:1893:25c8:1946'
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract single record type', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{ name: 'example.com', type: 'A', ttl: 300, data: '93.184.216.34' },
|
||||
{ name: 'example.com', type: 'A', ttl: 600, data: '93.184.216.35' },
|
||||
{
|
||||
name: 'example.com',
|
||||
type: 'AAAA',
|
||||
ttl: 300,
|
||||
data: '2606:2800:220:1:248:1893:25c8:1946'
|
||||
}
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, ['A']);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((r) => r.type === 'A')).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve all record properties', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{
|
||||
name: 'sub.example.com',
|
||||
type: 'CNAME',
|
||||
ttl: 3600,
|
||||
data: 'target.example.com'
|
||||
}
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, ['CNAME']);
|
||||
|
||||
expect(result[0].name).toBe('sub.example.com');
|
||||
expect(result[0].type).toBe('CNAME');
|
||||
expect(result[0].ttl).toBe(3600);
|
||||
expect(result[0].data).toBe('target.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty array when no matching types', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{ name: 'example.com', type: 'A', ttl: 300, data: '93.184.216.34' },
|
||||
{
|
||||
name: 'example.com',
|
||||
type: 'AAAA',
|
||||
ttl: 300,
|
||||
data: '2606:2800:220:1:248:1893:25c8:1946'
|
||||
}
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, ['CNAME', 'NS']);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array for empty answers', () => {
|
||||
const result = extractStringAnswer([], ['A', 'AAAA']);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array for empty target types', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{ name: 'example.com', type: 'A', ttl: 300, data: '93.184.216.34' }
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, []);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle answers with undefined ttl', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{
|
||||
name: 'example.com',
|
||||
type: 'A',
|
||||
ttl: undefined,
|
||||
data: '93.184.216.34'
|
||||
}
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, ['A']);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].ttl).toBe(0); // Should default to 0 when ttl is undefined
|
||||
});
|
||||
});
|
||||
|
||||
describe('various DNS record types', () => {
|
||||
it('should extract CNAME records', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{ name: 'www.example.com', type: 'CNAME', ttl: 300, data: 'example.com' }
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, ['CNAME']);
|
||||
|
||||
expect(result[0].type).toBe('CNAME');
|
||||
expect(result[0].data).toBe('example.com');
|
||||
});
|
||||
|
||||
it('should extract NS records', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{ name: 'example.com', type: 'NS', ttl: 86400, data: 'ns1.example.com' }
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, ['NS']);
|
||||
|
||||
expect(result[0].type).toBe('NS');
|
||||
expect(result[0].data).toBe('ns1.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering logic', () => {
|
||||
it('should filter by multiple target types', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{ name: 'example.com', type: 'A', ttl: 300, data: '93.184.216.34' },
|
||||
{
|
||||
name: 'example.com',
|
||||
type: 'AAAA',
|
||||
ttl: 300,
|
||||
data: '2606:2800:220:1:248:1893:25c8:1946'
|
||||
},
|
||||
{ name: 'example.com', type: 'CNAME', ttl: 300, data: 'www.example.com' },
|
||||
{ name: 'example.com', type: 'MX' as any, ttl: 3600, data: '10 mail.example.com' }
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, ['A', 'AAAA', 'CNAME']);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((r) => r.type).sort()).toEqual(['A', 'AAAA', 'CNAME']);
|
||||
});
|
||||
|
||||
it('should maintain order of answers', () => {
|
||||
const answers: StringAnswer[] = [
|
||||
{ name: 'example.com', type: 'A', ttl: 300, data: '93.184.216.34' },
|
||||
{ name: 'example.com', type: 'A', ttl: 300, data: '93.184.216.35' },
|
||||
{ name: 'example.com', type: 'A', ttl: 300, data: '93.184.216.36' }
|
||||
];
|
||||
|
||||
const result = extractStringAnswer(answers, ['A']);
|
||||
|
||||
expect(result.map((r) => r.data)).toEqual([
|
||||
'93.184.216.34',
|
||||
'93.184.216.35',
|
||||
'93.184.216.36'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('using fixture data', () => {
|
||||
it('should extract A records from fixture response', () => {
|
||||
const query = dnsFixtures.queries['199.43.135.53']?.['example.org:A'];
|
||||
if (query?.response?.answers) {
|
||||
const results = extractStringAnswer(query.response.answers as StringAnswer[], ['A']);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
results.forEach((record) => {
|
||||
expect(record.type).toBe('A');
|
||||
expect(record.data).toMatch(/^\d+\.\d+\.\d+\.\d+$/); // IPv4 format
|
||||
expect(record.name).toBe('example.org');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract AAAA records from fixture response', () => {
|
||||
const query = dnsFixtures.queries['199.43.135.53']?.['example.org:AAAA'];
|
||||
if (query?.response?.answers) {
|
||||
const results = extractStringAnswer(query.response.answers as StringAnswer[], ['AAAA']);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
results.forEach((record) => {
|
||||
expect(record.type).toBe('AAAA');
|
||||
expect(record.data).toContain(':'); // IPv6 format
|
||||
expect(record.name).toBe('example.org');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract NS records from fixture response', () => {
|
||||
const query = dnsFixtures.queries['199.43.135.53']?.['example.org:NS'];
|
||||
if (query?.response?.answers) {
|
||||
const results = extractStringAnswer(query.response.answers as StringAnswer[], ['NS']);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
results.forEach((record) => {
|
||||
expect(record.type).toBe('NS');
|
||||
expect(record.data).toContain('.');
|
||||
expect(record.name).toBe('example.org');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract CNAME records from fixture response', () => {
|
||||
const query = dnsFixtures.queries['199.43.135.53']?.['www.example.org:CNAME'];
|
||||
if (query?.response?.answers) {
|
||||
const results = extractStringAnswer(query.response.answers as StringAnswer[], ['CNAME']);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
results.forEach((record) => {
|
||||
expect(record.type).toBe('CNAME');
|
||||
expect(record.data).toContain('.');
|
||||
expect(record.name).toBe('www.example.org');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNSServerMatch', () => {
|
||||
it('should return at most MAX_NAMESERVERS nameservers', () => {
|
||||
const answers = [
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns1.example.org' },
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns2.example.org' },
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns3.example.org' },
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns4.example.org' },
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns5.example.org' },
|
||||
{ name: 'ns1.example.org', type: 'A', ttl: 172800, data: '1.1.1.1' },
|
||||
{ name: 'ns2.example.org', type: 'A', ttl: 172800, data: '2.2.2.2' },
|
||||
{ name: 'ns3.example.org', type: 'A', ttl: 172800, data: '3.3.3.3' },
|
||||
{ name: 'ns4.example.org', type: 'A', ttl: 172800, data: '4.4.4.4' },
|
||||
{ name: 'ns5.example.org', type: 'A', ttl: 172800, data: '5.5.5.5' }
|
||||
] as any;
|
||||
|
||||
const result = extractNSServerMatch('example.org', answers);
|
||||
|
||||
expect(result?.zone).toBe('example.org');
|
||||
expect(result?.nameservers.length).toBe(MAX_NAMESERVERS);
|
||||
expect(result?.nameservers[0].ns).toBe('ns1.example.org');
|
||||
expect(result?.nameservers[1].ns).toBe('ns2.example.org');
|
||||
expect(result?.nameservers[2].ns).toBe('ns3.example.org');
|
||||
});
|
||||
|
||||
it('should return all nameservers if less than MAX_NAMESERVERS', () => {
|
||||
const answers = [
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns1.example.org' },
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns2.example.org' },
|
||||
{ name: 'ns1.example.org', type: 'A', ttl: 172800, data: '1.1.1.1' },
|
||||
{ name: 'ns2.example.org', type: 'A', ttl: 172800, data: '2.2.2.2' }
|
||||
] as any;
|
||||
|
||||
const result = extractNSServerMatch('example.org', answers);
|
||||
|
||||
expect(result?.zone).toBe('example.org');
|
||||
expect(result?.nameservers.length).toBe(2);
|
||||
expect(result?.nameservers[0].ns).toBe('ns1.example.org');
|
||||
expect(result?.nameservers[1].ns).toBe('ns2.example.org');
|
||||
});
|
||||
|
||||
it('should return exactly MAX_NAMESERVERS when there are exactly MAX_NAMESERVERS nameservers', () => {
|
||||
const answers = [
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns1.example.org' },
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns2.example.org' },
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns3.example.org' },
|
||||
{ name: 'ns1.example.org', type: 'A', ttl: 172800, data: '1.1.1.1' },
|
||||
{ name: 'ns2.example.org', type: 'A', ttl: 172800, data: '2.2.2.2' },
|
||||
{ name: 'ns3.example.org', type: 'A', ttl: 172800, data: '3.3.3.3' }
|
||||
] as any;
|
||||
|
||||
const result = extractNSServerMatch('example.org', answers);
|
||||
|
||||
expect(result?.zone).toBe('example.org');
|
||||
expect(result?.nameservers.length).toBe(MAX_NAMESERVERS);
|
||||
expect(result?.nameservers[0].ns).toBe('ns1.example.org');
|
||||
expect(result?.nameservers[1].ns).toBe('ns2.example.org');
|
||||
expect(result?.nameservers[2].ns).toBe('ns3.example.org');
|
||||
});
|
||||
|
||||
it('should filter by target domain name', () => {
|
||||
const answers = [
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns1.example.org' },
|
||||
{ name: 'example.com', type: 'NS', ttl: 86400, data: 'ns1.example.com' },
|
||||
{ name: 'example.org', type: 'NS', ttl: 86400, data: 'ns2.example.org' },
|
||||
{ name: 'ns1.example.org', type: 'A', ttl: 172800, data: '1.1.1.1' },
|
||||
{ name: 'ns2.example.org', type: 'A', ttl: 172800, data: '2.2.2.2' }
|
||||
] as any;
|
||||
|
||||
const result = extractNSServerMatch('example.org', answers);
|
||||
|
||||
expect(result?.zone).toBe('example.org');
|
||||
expect(result?.nameservers.length).toBe(2);
|
||||
expect(result?.nameservers[0].ns).toBe('ns1.example.org');
|
||||
expect(result?.nameservers[1].ns).toBe('ns2.example.org');
|
||||
});
|
||||
|
||||
it('should use the closest parent NS records in a referral response', () => {
|
||||
const answers = [
|
||||
{ name: 'test', type: 'NS', ttl: 86400, data: 'a.test-servers.example' },
|
||||
{ name: 'example.test', type: 'NS', ttl: 86400, data: 'ns1.example.test' },
|
||||
{ name: 'a.test-servers.example', type: 'A', ttl: 172800, data: '192.0.2.1' },
|
||||
{ name: 'ns1.example.test', type: 'A', ttl: 172800, data: '198.51.100.1' }
|
||||
] as any;
|
||||
|
||||
const result = extractNSServerMatch('app.platform.example.test', answers);
|
||||
|
||||
expect(result?.zone).toBe('example.test');
|
||||
expect(result?.nameservers.map((ns) => ns.ns)).toEqual(['ns1.example.test']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryDns', () => {
|
||||
it('should query A record using fixture data', async () => {
|
||||
const query = dnsFixtures.queries['199.43.135.53']?.['example.org:A'];
|
||||
if (query) {
|
||||
const response = await queryDns('example.org', 'A', '199.43.135.53');
|
||||
|
||||
expect(response.type).toBe('response');
|
||||
expect(response.questions?.[0]?.name).toBe('example.org');
|
||||
expect(response.questions?.[0]?.type).toBe('A');
|
||||
expect(response.answers).toBeDefined();
|
||||
expect(Array.isArray(response.answers)).toBe(true);
|
||||
expect(response.answers!.length).toBeGreaterThan(0);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should query AAAA record using fixture data', async () => {
|
||||
const query = dnsFixtures.queries['199.43.135.53']?.['example.org:AAAA'];
|
||||
if (query) {
|
||||
const response = await queryDns('example.org', 'AAAA', '199.43.135.53');
|
||||
|
||||
expect(response.type).toBe('response');
|
||||
expect(response.questions?.[0]?.name).toBe('example.org');
|
||||
expect(response.questions?.[0]?.type).toBe('AAAA');
|
||||
expect(response.answers).toBeDefined();
|
||||
expect(Array.isArray(response.answers)).toBe(true);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should query NS record using fixture data', async () => {
|
||||
const query = dnsFixtures.queries['199.43.135.53']?.['example.org:NS'];
|
||||
if (query) {
|
||||
const response = await queryDns('example.org', 'NS', '199.43.135.53');
|
||||
|
||||
expect(response.type).toBe('response');
|
||||
expect(response.questions?.[0]?.name).toBe('example.org');
|
||||
expect(response.questions?.[0]?.type).toBe('NS');
|
||||
expect(response.answers).toBeDefined();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it('should query CNAME record using fixture data', async () => {
|
||||
const query = dnsFixtures.queries['199.43.135.53']?.['www.example.org:CNAME'];
|
||||
if (query) {
|
||||
const response = await queryDns('www.example.org', 'CNAME', '199.43.135.53');
|
||||
|
||||
expect(response.type).toBe('response');
|
||||
expect(response.questions?.[0]?.name).toBe('www.example.org');
|
||||
expect(response.questions?.[0]?.type).toBe('CNAME');
|
||||
expect(response.answers).toBeDefined();
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('queryA', () => {
|
||||
it('should query A record for example.org', async () => {
|
||||
const result = await queryA('example.org');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('A');
|
||||
expect(result?.name).toBe('example.org');
|
||||
expect(result?.data).toMatch(/^\d+\.\d+\.\d+\.\d+$/); // IPv4 format
|
||||
}, 2000);
|
||||
|
||||
it('should query A record for example.com', async () => {
|
||||
const result = await queryA('example.com');
|
||||
|
||||
if (result) {
|
||||
expect(result.type).toBe('A');
|
||||
expect(result.name).toBe('example.com');
|
||||
expect(result.data).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
describe('queryAAAA', () => {
|
||||
it('should query AAAA record for example.org', async () => {
|
||||
const result = await queryAAAA('example.org');
|
||||
|
||||
if (result) {
|
||||
expect(result.type).toBe('AAAA');
|
||||
expect(result.name).toBe('example.org');
|
||||
expect(result.data).toContain(':'); // IPv6 format
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
describe('testCname', () => {
|
||||
it('should test CNAME record for www.example.org', async () => {
|
||||
const target = findCnameRecord('www.example.org');
|
||||
if (target) {
|
||||
const result = await testCname('www.example.org', target);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('CNAME');
|
||||
expect(result?.name).toBe('www.example.org');
|
||||
expect(result?.data).toBe(target);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
it('should report the origin domain when followed CNAME resolution fails', async () => {
|
||||
await expect(
|
||||
testCname('mismatch.example.org', 'expected-target.example.org')
|
||||
).rejects.toThrow(ResolveError);
|
||||
|
||||
try {
|
||||
await testCname('mismatch.example.org', 'expected-target.example.org');
|
||||
expect.fail('Should have thrown ResolveError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResolveError);
|
||||
if (error instanceof ResolveError) {
|
||||
expect(error.code).toBe(ResolveErrorCode.NO_RECORD);
|
||||
expect(error.domain).toBe('mismatch.example.org');
|
||||
expect((error.details as any)?.failedDomain).toBe('wrong-target.example.org');
|
||||
expect((error.details as any)?.cnameChain?.[0]?.data).toBe('wrong-target.example.org');
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
it('should log trace when resolution fails', async () => {
|
||||
await expect(
|
||||
testCname('mismatch.example.org', 'expected-target.example.org')
|
||||
).rejects.toThrow(ResolveError);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[dns-resolver] resolve failed',
|
||||
expect.stringContaining('"trace"')
|
||||
);
|
||||
}, 2000);
|
||||
|
||||
it('should use root lookup when local lookup only returns a TLD referral', async () => {
|
||||
const result = await testCname('app.platform.example.test', 'target.platform.example.test');
|
||||
|
||||
expect(result.data).toBe('target.platform.example.test');
|
||||
expect((result as any).ns.host).toBe('ns1.example.test');
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
describe('queryDnsFollowCname (via testCname)', () => {
|
||||
it('should detect CNAME loop when following CNAME chain', async () => {
|
||||
await expect(testCname('a.example.org', 'nonexistent.example.org')).rejects.toThrow(
|
||||
ResolveError
|
||||
);
|
||||
|
||||
try {
|
||||
await testCname('a.example.org', 'nonexistent.example.org');
|
||||
expect.fail('Should have thrown ResolveError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResolveError);
|
||||
if (error instanceof ResolveError) {
|
||||
expect(error.code).toBe(ResolveErrorCode.CNAME_LOOP);
|
||||
expect(error.domain).toBe('a.example.org');
|
||||
expect((error.details as any)?.visited).toContain('a.example.org');
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
it('should throw MAX_CNAME_STEPS_EXCEEDED for deep CNAME chain', async () => {
|
||||
await expect(testCname('deep1.example.org', 'nonexistent.example.org')).rejects.toThrow(
|
||||
ResolveError
|
||||
);
|
||||
|
||||
try {
|
||||
await testCname('deep1.example.org', 'nonexistent.example.org');
|
||||
expect.fail('Should have thrown ResolveError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResolveError);
|
||||
if (error instanceof ResolveError) {
|
||||
expect(error.code).toBe(ResolveErrorCode.MAX_CNAME_STEPS_EXCEEDED);
|
||||
expect((error.details as any)?.step).toBeGreaterThan(4);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
describe('queryDnsFollowCname (via queryA)', () => {
|
||||
it('should throw CNAME_LOOP error when following CNAME chain creates a loop', async () => {
|
||||
await expect(queryA('a.example.org')).rejects.toThrow(ResolveError);
|
||||
|
||||
try {
|
||||
await queryA('a.example.org');
|
||||
expect.fail('Should have thrown ResolveError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResolveError);
|
||||
if (error instanceof ResolveError) {
|
||||
expect(error.code).toBe(ResolveErrorCode.CNAME_LOOP);
|
||||
expect(error.domain).toBe('a.example.org');
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
it('should throw MAX_CNAME_STEPS_EXCEEDED for deep CNAME chain in queryA', async () => {
|
||||
await expect(queryA('deep1.example.org')).rejects.toThrow(ResolveError);
|
||||
|
||||
try {
|
||||
await queryA('deep1.example.org');
|
||||
expect.fail('Should have thrown ResolveError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResolveError);
|
||||
if (error instanceof ResolveError) {
|
||||
expect(error.code).toBe(ResolveErrorCode.MAX_CNAME_STEPS_EXCEEDED);
|
||||
expect((error.details as any)?.step).toBeGreaterThan(4);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
describe('queryDnsFollowCname (via queryAAAA)', () => {
|
||||
it('should throw CNAME_LOOP error when following CNAME chain creates a loop', async () => {
|
||||
await expect(queryAAAA('a.example.org')).rejects.toThrow(ResolveError);
|
||||
|
||||
try {
|
||||
await queryAAAA('a.example.org');
|
||||
expect.fail('Should have thrown ResolveError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResolveError);
|
||||
if (error instanceof ResolveError) {
|
||||
expect(error.code).toBe(ResolveErrorCode.CNAME_LOOP);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
describe('resolveWithNs', () => {
|
||||
it('should try multiple nameservers and return result from the last working one', async () => {
|
||||
const badNs1: Nameserver = {
|
||||
ns: 'bad-ns1.example.org',
|
||||
resolveIPv4: async () => '199.43.135.53',
|
||||
resolveIPv6: async () => null
|
||||
};
|
||||
|
||||
const badNs2: Nameserver = {
|
||||
ns: 'bad-ns2.example.org',
|
||||
resolveIPv4: async () => '199.43.133.53',
|
||||
resolveIPv6: async () => null
|
||||
};
|
||||
|
||||
const goodNs: Nameserver = {
|
||||
ns: 'good-ns.example.org',
|
||||
resolveIPv4: async () => '10.0.0.1',
|
||||
resolveIPv6: async () => null
|
||||
};
|
||||
|
||||
const nameservers = [badNs1, badNs2, goodNs];
|
||||
|
||||
const result = await resolveWithNs('test.example.org', 'A', nameservers);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
if (!result) {
|
||||
throw new Error('Result is null');
|
||||
}
|
||||
expect(result.type).toBe('response');
|
||||
expect(result.questions?.[0]?.name).toBe('test.example.org');
|
||||
expect(result.questions?.[0]?.type).toBe('A');
|
||||
expect((result as any).rcode).toBe('NOERROR');
|
||||
expect(result.answers).toBeDefined();
|
||||
expect(Array.isArray(result.answers)).toBe(true);
|
||||
expect(result.answers?.length).toBeGreaterThan(0);
|
||||
expect((result.answers?.[0] as any)?.data).toBe('192.168.1.1');
|
||||
expect(result.ns?.host).toBe('good-ns.example.org');
|
||||
expect(result.ns?.ip).toBe('10.0.0.1');
|
||||
}, 2000);
|
||||
|
||||
it('should return null if all nameservers fail', async () => {
|
||||
const badNs1: Nameserver = {
|
||||
ns: 'bad-ns1.example.org',
|
||||
resolveIPv4: async () => '199.43.135.53',
|
||||
resolveIPv6: async () => null
|
||||
};
|
||||
|
||||
const badNs2: Nameserver = {
|
||||
ns: 'bad-ns2.example.org',
|
||||
resolveIPv4: async () => '199.43.133.53',
|
||||
resolveIPv6: async () => null
|
||||
};
|
||||
|
||||
const nameservers = [badNs1, badNs2];
|
||||
|
||||
const result = await resolveWithNs('nonexistent.example.org', 'A', nameservers);
|
||||
|
||||
expect(result).toBeNull();
|
||||
}, 2000);
|
||||
|
||||
it('should ignore SOA-only authority responses for NS queries', async () => {
|
||||
const soaOnlyNs: Nameserver = {
|
||||
ns: 'soa-only.example.test',
|
||||
resolveIPv4: async () => '203.0.113.2',
|
||||
resolveIPv6: async () => null
|
||||
};
|
||||
|
||||
const result = await resolveWithNs('soa-only.example.test', 'NS', [soaOnlyNs]);
|
||||
|
||||
expect(result).toBeNull();
|
||||
}, 2000);
|
||||
|
||||
it('should skip nameservers without IP addresses', async () => {
|
||||
const noIpNs: Nameserver = {
|
||||
ns: 'no-ip-ns.example.org',
|
||||
resolveIPv4: async () => null,
|
||||
resolveIPv6: async () => null
|
||||
};
|
||||
|
||||
const goodNs: Nameserver = {
|
||||
ns: 'good-ns.example.org',
|
||||
resolveIPv4: async () => '10.0.0.1',
|
||||
resolveIPv6: async () => null
|
||||
};
|
||||
|
||||
const nameservers = [noIpNs, goodNs];
|
||||
|
||||
const result = await resolveWithNs('test.example.org', 'A', nameservers);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.ns?.host).toBe('good-ns.example.org');
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
describe('getAuthoritativeNsFromRoot', () => {
|
||||
it('should get authoritative NS for example.org from root', async () => {
|
||||
const result = await getAuthoritativeNsFromRoot('example.org');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.zone).toBeTruthy();
|
||||
expect(result?.nameservers).toBeDefined();
|
||||
expect(Array.isArray(result?.nameservers)).toBe(true);
|
||||
expect(result?.nameservers.length).toBeGreaterThan(0);
|
||||
|
||||
result?.nameservers.forEach((ns) => {
|
||||
expect(ns.ns).toBeTruthy();
|
||||
expect(typeof ns.ns).toBe('string');
|
||||
expect(typeof ns.resolveIPv4).toBe('function');
|
||||
expect(typeof ns.resolveIPv6).toBe('function');
|
||||
});
|
||||
|
||||
if (result && result.nameservers.length > 0) {
|
||||
const firstNs = result.nameservers[0];
|
||||
const ipv4 = await firstNs.resolveIPv4();
|
||||
if (ipv4) {
|
||||
expect(ipv4).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
it('should get authoritative NS for example.com from root', async () => {
|
||||
const result = await getAuthoritativeNsFromRoot('example.com');
|
||||
|
||||
if (result) {
|
||||
expect(result.zone).toBeTruthy();
|
||||
expect(result.nameservers.length).toBeGreaterThan(0);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
it('should get authoritative NS for org TLD from root', async () => {
|
||||
const result = await getAuthoritativeNsFromRoot('org');
|
||||
|
||||
if (result) {
|
||||
expect(result.zone).toBe('org');
|
||||
expect(result.nameservers.length).toBeGreaterThan(0);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
it('should follow root referrals to the closest authoritative zone', async () => {
|
||||
const result = await getAuthoritativeNsFromRoot('app.platform.example.test');
|
||||
|
||||
expect(result?.zone).toBe('example.test');
|
||||
expect(result?.nameservers.map((ns) => ns.ns)).toEqual([
|
||||
'ns1.example.test',
|
||||
'ns2.example.test'
|
||||
]);
|
||||
}, 2000);
|
||||
|
||||
it('should throw error for domain that is too long', async () => {
|
||||
const longDomain = Array(12).fill('subdomain').join('.') + '.com';
|
||||
|
||||
await expect(getAuthoritativeNsFromRoot(longDomain)).rejects.toThrow(ResolveError);
|
||||
try {
|
||||
await getAuthoritativeNsFromRoot(longDomain);
|
||||
expect.fail('Should have thrown ResolveError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResolveError);
|
||||
if (error instanceof ResolveError) {
|
||||
expect(error.code).toBe(ResolveErrorCode.DOMAIN_TOO_LONG);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthoritativeNsFromLocal', () => {
|
||||
it('should get authoritative NS for example.org from local resolver', async () => {
|
||||
const result = await getAuthoritativeNsFromLocal('example.org');
|
||||
|
||||
if (result) {
|
||||
expect(result.zone).toBeTruthy();
|
||||
expect(result.nameservers).toBeDefined();
|
||||
expect(Array.isArray(result.nameservers)).toBe(true);
|
||||
expect(result.nameservers.length).toBeGreaterThan(0);
|
||||
|
||||
result.nameservers.forEach((ns) => {
|
||||
expect(ns.ns).toBeTruthy();
|
||||
expect(typeof ns.ns).toBe('string');
|
||||
expect(typeof ns.resolveIPv4).toBe('function');
|
||||
expect(typeof ns.resolveIPv6).toBe('function');
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
it('should get authoritative NS for example.com from local resolver', async () => {
|
||||
const result = await getAuthoritativeNsFromLocal('example.com');
|
||||
|
||||
if (result) {
|
||||
expect(result.zone).toBeTruthy();
|
||||
expect(result.nameservers.length).toBeGreaterThan(0);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
it('should follow local resolver referrals to the closest authoritative zone', async () => {
|
||||
const result = await getAuthoritativeNsFromLocal('app.platform.example.test');
|
||||
|
||||
expect(result?.zone).toBe('example.test');
|
||||
expect(result?.nameservers.map((ns) => ns.ns)).toEqual([
|
||||
'ns1.example.test',
|
||||
'ns2.example.test'
|
||||
]);
|
||||
}, 2000);
|
||||
|
||||
it('should return null for empty domain', async () => {
|
||||
const result = await getAuthoritativeNsFromLocal('');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error for domain that is too long', async () => {
|
||||
const longDomain = Array(12).fill('subdomain').join('.') + '.com';
|
||||
|
||||
try {
|
||||
await getAuthoritativeNsFromLocal(longDomain);
|
||||
expect.fail('Should have thrown ResolveError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResolveError);
|
||||
if (error instanceof ResolveError) {
|
||||
expect(error.code).toBe(ResolveErrorCode.DOMAIN_TOO_LONG);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getAppMonitorData, getAppPodsByAppName } from '@/api/app';
|
||||
import { appStatusMap } from '@/constants/app';
|
||||
import { MOCK_APPS } from '@/mock/apps';
|
||||
import { MOCK_APP_DETAIL } from '@/mock/apps';
|
||||
import { useAppStore } from '@/store/app';
|
||||
|
||||
vi.mock('@/api/app', () => ({
|
||||
getAppByName: vi.fn(),
|
||||
getAppMonitorData: vi.fn(),
|
||||
getAppPodsByAppName: vi.fn(),
|
||||
getMyApps: vi.fn()
|
||||
}));
|
||||
|
||||
const mockedGetAppPodsByAppName = vi.mocked(getAppPodsByAppName);
|
||||
const mockedGetAppMonitorData = vi.mocked(getAppMonitorData);
|
||||
|
||||
describe('useAppStore intervalLoadPods', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppStore.setState({
|
||||
appList: [],
|
||||
appDetail: {
|
||||
...MOCK_APP_DETAIL,
|
||||
status: appStatusMap.error
|
||||
},
|
||||
appDetailPods: [],
|
||||
appListPagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 10
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the app detail error status after an empty pod query completes', async () => {
|
||||
mockedGetAppPodsByAppName.mockResolvedValueOnce([]);
|
||||
|
||||
await useAppStore.getState().intervalLoadPods(MOCK_APP_DETAIL.appName, true);
|
||||
|
||||
expect(useAppStore.getState().appDetail?.status).toBe(appStatusMap.error);
|
||||
});
|
||||
|
||||
it('keeps the app list error status after an empty pod query completes', async () => {
|
||||
useAppStore.setState({
|
||||
appList: [
|
||||
{
|
||||
...MOCK_APPS[0],
|
||||
name: MOCK_APP_DETAIL.appName,
|
||||
status: appStatusMap.error
|
||||
}
|
||||
],
|
||||
appDetail: {
|
||||
...MOCK_APP_DETAIL,
|
||||
appName: 'another-app'
|
||||
},
|
||||
appDetailPods: []
|
||||
});
|
||||
mockedGetAppPodsByAppName.mockResolvedValueOnce([]);
|
||||
|
||||
await useAppStore.getState().intervalLoadPods(MOCK_APP_DETAIL.appName, false);
|
||||
|
||||
expect(useAppStore.getState().appList[0]?.status).toBe(appStatusMap.error);
|
||||
});
|
||||
|
||||
it('sets detail CPU and memory usage to zero when the app has no pods', async () => {
|
||||
mockedGetAppPodsByAppName.mockResolvedValueOnce([]);
|
||||
mockedGetAppMonitorData.mockResolvedValue([
|
||||
{
|
||||
name: MOCK_APP_DETAIL.appName,
|
||||
xData: [1],
|
||||
yData: ['9']
|
||||
}
|
||||
]);
|
||||
|
||||
await useAppStore.getState().loadDetailMonitorData(MOCK_APP_DETAIL.appName);
|
||||
|
||||
expect(mockedGetAppMonitorData).not.toHaveBeenCalled();
|
||||
expect(useAppStore.getState().appDetail?.usedCpu.yData).toEqual(new Array(30).fill('0'));
|
||||
expect(useAppStore.getState().appDetail?.usedMemory.yData).toEqual(new Array(30).fill('0'));
|
||||
});
|
||||
|
||||
it('sets list CPU and memory usage to zero when the app has no pods', async () => {
|
||||
useAppStore.setState({
|
||||
appList: [
|
||||
{
|
||||
...MOCK_APPS[0],
|
||||
name: MOCK_APP_DETAIL.appName,
|
||||
usedCpu: { name: 'old-cpu', xData: [1], yData: ['9'] },
|
||||
usedMemory: { name: 'old-memory', xData: [1], yData: ['8'] }
|
||||
}
|
||||
]
|
||||
});
|
||||
mockedGetAppPodsByAppName.mockResolvedValueOnce([]);
|
||||
mockedGetAppMonitorData.mockResolvedValue([
|
||||
{
|
||||
name: MOCK_APP_DETAIL.appName,
|
||||
xData: [1],
|
||||
yData: ['9']
|
||||
}
|
||||
]);
|
||||
|
||||
await useAppStore.getState().loadAvgMonitorData(MOCK_APP_DETAIL.appName);
|
||||
|
||||
expect(mockedGetAppMonitorData).not.toHaveBeenCalled();
|
||||
expect(useAppStore.getState().appList[0]?.usedCpu.yData).toEqual(new Array(30).fill('0'));
|
||||
expect(useAppStore.getState().appList[0]?.usedMemory.yData).toEqual(new Array(30).fill('0'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAppStore app list pagination', () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({
|
||||
appListPagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 10
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('stores app list pagination globally', () => {
|
||||
useAppStore.getState().setAppListPagination({ pageIndex: 2, pageSize: 20 });
|
||||
|
||||
expect(useAppStore.getState().appListPagination).toEqual({
|
||||
pageIndex: 2,
|
||||
pageSize: 20
|
||||
});
|
||||
});
|
||||
|
||||
it('updates one pagination field without resetting the other', () => {
|
||||
useAppStore.getState().setAppListPagination({ pageIndex: 3, pageSize: 20 });
|
||||
|
||||
useAppStore.getState().setAppListPagination({ pageIndex: 1 });
|
||||
|
||||
expect(useAppStore.getState().appListPagination).toEqual({
|
||||
pageIndex: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateLaunchpadRequestSchema } from '@/types/request_schema';
|
||||
import { CreateLaunchpadRequestSchema as CreateLaunchpadV2AlphaRequestSchema } from '@/types/v2alpha/request_schema';
|
||||
import { APP_NAME_BASE_MAX_LENGTH } from '@/utils/appNameValidation';
|
||||
|
||||
const schemas = [
|
||||
['v1', CreateLaunchpadRequestSchema, { resource: { replicas: 1 } }],
|
||||
['v2alpha', CreateLaunchpadV2AlphaRequestSchema, { quota: { replicas: 1 } }]
|
||||
] as const;
|
||||
|
||||
describe.each(schemas)('%s create app name schema', (_version, schema, minimalPayload) => {
|
||||
it('accepts app names up to the service and pod-safe length budget', () => {
|
||||
const maxLengthName = `a${'b'.repeat(APP_NAME_BASE_MAX_LENGTH - 1)}`;
|
||||
|
||||
expect(schema.safeParse({ ...minimalPayload, name: 'demo' }).success).toBe(true);
|
||||
expect(maxLengthName).toHaveLength(25);
|
||||
expect(schema.safeParse({ ...minimalPayload, name: maxLengthName }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects app names that would break RFC 1035 service or pod suffix budgets', () => {
|
||||
const tooLongName = `a${'b'.repeat(APP_NAME_BASE_MAX_LENGTH)}`;
|
||||
|
||||
expect(schema.safeParse({ ...minimalPayload, name: tooLongName }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...minimalPayload, name: '1app' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...minimalPayload, name: 'App' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...minimalPayload, name: 'app_1' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...minimalPayload, name: 'app-' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...minimalPayload, name: 'app.name' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...minimalPayload, name: 'app name' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the default name valid', () => {
|
||||
const result = schema.safeParse(minimalPayload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('hello-world');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Quantity } from '@sealos/shared';
|
||||
import { transformFromLegacySchema, transformToLegacySchema } from '@/types/request_schema';
|
||||
import {
|
||||
transformFromLegacySchema as transformFromLegacySchemaV2,
|
||||
transformToLegacySchema as transformToLegacySchemaV2
|
||||
} from '@/types/v2alpha/request_schema';
|
||||
import type { AppDetailType } from '@/types/app';
|
||||
|
||||
const baseV1Request = {
|
||||
name: 'demo',
|
||||
image: { imageName: 'nginx' },
|
||||
launchCommand: {},
|
||||
resource: {
|
||||
replicas: 1,
|
||||
cpu: 0.5,
|
||||
memory: 1
|
||||
},
|
||||
ports: [],
|
||||
env: [],
|
||||
storage: [],
|
||||
configMap: []
|
||||
};
|
||||
|
||||
const baseV2Request = {
|
||||
name: 'demo',
|
||||
image: { imageName: 'nginx' },
|
||||
launchCommand: {},
|
||||
quota: {
|
||||
replicas: 1,
|
||||
cpu: 0.5,
|
||||
memory: 1
|
||||
},
|
||||
ports: [],
|
||||
env: [],
|
||||
storage: [],
|
||||
configMap: []
|
||||
};
|
||||
|
||||
const baseDetail = {
|
||||
appName: 'demo',
|
||||
imageName: 'nginx',
|
||||
runCMD: '',
|
||||
cmdParam: '',
|
||||
replicas: 1,
|
||||
cpu: Quantity.parse('500m'),
|
||||
memory: Quantity.parse('1Gi'),
|
||||
networks: [],
|
||||
envs: [],
|
||||
hpa: {
|
||||
use: false,
|
||||
target: 'cpu',
|
||||
value: 50,
|
||||
minReplicas: 1,
|
||||
maxReplicas: 5
|
||||
},
|
||||
secret: {
|
||||
use: false,
|
||||
username: '',
|
||||
password: '',
|
||||
serverAddress: 'docker.io'
|
||||
},
|
||||
configMapList: [],
|
||||
storeList: [],
|
||||
labels: {},
|
||||
volumes: [],
|
||||
volumeMounts: [],
|
||||
kind: 'deployment',
|
||||
id: 'uid',
|
||||
createTime: '2024-01-01 00:00',
|
||||
status: {
|
||||
label: 'Running',
|
||||
value: 'running',
|
||||
color: '',
|
||||
backgroundColor: '',
|
||||
dotColor: ''
|
||||
},
|
||||
isPause: false,
|
||||
usedCpu: { name: '', xData: [], yData: [] },
|
||||
usedMemory: { name: '', xData: [], yData: [] },
|
||||
crYamlList: [],
|
||||
source: {
|
||||
hasSource: false,
|
||||
sourceName: '',
|
||||
sourceType: 'app_store'
|
||||
},
|
||||
openapi: {
|
||||
status: {
|
||||
observedGeneration: 1,
|
||||
replicas: 1,
|
||||
availableReplicas: 1,
|
||||
updatedReplicas: 1,
|
||||
isPause: false
|
||||
}
|
||||
}
|
||||
} satisfies AppDetailType;
|
||||
|
||||
describe('v1 public schema transforms', () => {
|
||||
it('converts public numeric resources into internal Quantity values', () => {
|
||||
const legacy = transformToLegacySchema(baseV1Request);
|
||||
|
||||
expect(legacy.cpu.toString()).toBe('500m');
|
||||
expect(legacy.memory.toString()).toBe('1Gi');
|
||||
});
|
||||
|
||||
it('converts internal Quantity resources back to public numbers', () => {
|
||||
const response = transformFromLegacySchema(baseDetail);
|
||||
|
||||
expect(response.resource.cpu).toBe(0.5);
|
||||
expect(response.resource.memory).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v2alpha public schema transforms', () => {
|
||||
it('converts public numeric quota into internal Quantity values', () => {
|
||||
const legacy = transformToLegacySchemaV2(baseV2Request);
|
||||
|
||||
expect(legacy.cpu.toString()).toBe('500m');
|
||||
expect(legacy.memory.toString()).toBe('1Gi');
|
||||
});
|
||||
|
||||
it('converts internal Quantity quota back to public numbers', () => {
|
||||
const response = transformFromLegacySchemaV2(baseDetail);
|
||||
|
||||
expect(response.quota.cpu).toBe(0.5);
|
||||
expect(response.quota.memory).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { V1Deployment } from '@kubernetes/client-node';
|
||||
|
||||
import { appStatusMap } from '@/constants/app';
|
||||
import { adaptAppListItem, normalizeCustomDomainHost } from '@/utils/adapt';
|
||||
|
||||
const createDeployment = (status: V1Deployment['status']): V1Deployment => ({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
metadata: {
|
||||
name: 'hello-world',
|
||||
uid: 'app-uid',
|
||||
creationTimestamp: new Date('2025-05-19T10:50:00Z')
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: 'hello-world'
|
||||
}
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: 'hello-world'
|
||||
}
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'hello-world',
|
||||
image: 'nginx',
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: '200m',
|
||||
memory: '256Mi'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
status
|
||||
});
|
||||
|
||||
describe('adaptAppListItem', () => {
|
||||
it('maps ProgressDeadlineExceeded deployments to the error status', () => {
|
||||
const app = createDeployment({
|
||||
replicas: 1,
|
||||
conditions: [
|
||||
{
|
||||
type: 'Progressing',
|
||||
status: 'False',
|
||||
reason: 'ProgressDeadlineExceeded'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(adaptAppListItem(app as Parameters<typeof adaptAppListItem>[0]).status).toBe(
|
||||
appStatusMap.error
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCustomDomainHost', () => {
|
||||
it('strips scheme and path from custom domains', () => {
|
||||
expect(normalizeCustomDomainHost('https://foo.example.com/app')).toBe('foo.example.com');
|
||||
expect(normalizeCustomDomainHost('http://Bar.Example.com')).toBe('bar.example.com');
|
||||
expect(normalizeCustomDomainHost('baz.example.com')).toBe('baz.example.com');
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
APP_NAME_BASE_MAX_LENGTH,
|
||||
K8S_RFC1035_NAME_MAX_LENGTH,
|
||||
getInvalidGeneratedAppNameMessage,
|
||||
getInvalidRfc1035ServiceNameMessage,
|
||||
generateAppName,
|
||||
isValidAppNameBase,
|
||||
isValidGeneratedAppName,
|
||||
isValidRfc1035Name
|
||||
} from '@/utils/appNameValidation';
|
||||
|
||||
describe('app name validation', () => {
|
||||
it('accepts valid user-entered base names up to the reserved suffix budget', () => {
|
||||
const maxLengthBase = `a${'b'.repeat(APP_NAME_BASE_MAX_LENGTH - 1)}`;
|
||||
|
||||
expect(isValidAppNameBase('a')).toBe(true);
|
||||
expect(isValidAppNameBase('app')).toBe(true);
|
||||
expect(isValidAppNameBase('app-1')).toBe(true);
|
||||
expect(isValidAppNameBase('hello-world')).toBe(true);
|
||||
expect(maxLengthBase).toHaveLength(25);
|
||||
expect(isValidAppNameBase(maxLengthBase)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects base names that would break RFC 1035 or reserved suffix budgets', () => {
|
||||
const tooLongBase = `a${'b'.repeat(APP_NAME_BASE_MAX_LENGTH)}`;
|
||||
|
||||
expect(isValidAppNameBase('')).toBe(false);
|
||||
expect(isValidAppNameBase('1app')).toBe(false);
|
||||
expect(isValidAppNameBase('App')).toBe(false);
|
||||
expect(isValidAppNameBase('app_1')).toBe(false);
|
||||
expect(isValidAppNameBase('-app')).toBe(false);
|
||||
expect(isValidAppNameBase('app-')).toBe(false);
|
||||
expect(isValidAppNameBase('app.name')).toBe(false);
|
||||
expect(isValidAppNameBase('app name')).toBe(false);
|
||||
expect(tooLongBase).toHaveLength(26);
|
||||
expect(isValidAppNameBase(tooLongBase)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the user-entered app name without appending a random suffix', () => {
|
||||
const generatedName = generateAppName('demo');
|
||||
|
||||
expect(generatedName).toBe('demo');
|
||||
expect(isValidGeneratedAppName(generatedName)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps app names, generated nodeport service names, and pod names inside RFC 1035', () => {
|
||||
const maxLengthBase = `a${'b'.repeat(APP_NAME_BASE_MAX_LENGTH - 1)}`;
|
||||
const generatedName = generateAppName(maxLengthBase);
|
||||
const serviceName = `${generatedName}-nodeport-${'c'.repeat(12)}`;
|
||||
const podName = `${serviceName}-${'d'.repeat(15)}`;
|
||||
|
||||
expect(generatedName).toHaveLength(APP_NAME_BASE_MAX_LENGTH);
|
||||
expect(isValidGeneratedAppName(generatedName)).toBe(true);
|
||||
expect(serviceName).toHaveLength(47);
|
||||
expect(podName).toHaveLength(K8S_RFC1035_NAME_MAX_LENGTH);
|
||||
expect(isValidGeneratedAppName(podName)).toBe(false);
|
||||
expect(/^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(podName)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports app names that exceed the generated name budget before apply', () => {
|
||||
const tooLongBase = `a${'b'.repeat(APP_NAME_BASE_MAX_LENGTH)}`;
|
||||
|
||||
expect(isValidRfc1035Name(tooLongBase)).toBe(true);
|
||||
expect(getInvalidGeneratedAppNameMessage(tooLongBase)).toContain(
|
||||
`Use ${APP_NAME_BASE_MAX_LENGTH} characters or fewer`
|
||||
);
|
||||
});
|
||||
|
||||
it('reports invalid service names before the Kubernetes apply step', () => {
|
||||
expect(
|
||||
getInvalidRfc1035ServiceNameMessage([
|
||||
{
|
||||
kind: 'Deployment',
|
||||
metadata: { name: '111111hello-world' }
|
||||
},
|
||||
{
|
||||
kind: 'Service',
|
||||
metadata: { name: '111111hello-world-yanexremmrtr' }
|
||||
}
|
||||
])
|
||||
).toContain('Service "111111hello-world-yanexremmrtr" has an invalid name');
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
json2DeployCr,
|
||||
json2Ingress,
|
||||
json2Service,
|
||||
yamlString2Objects
|
||||
} from '@/utils/deployYaml2Json';
|
||||
import type { AppEditType } from '@/types/app';
|
||||
import { deployPVCResizeKey } from '@/constants/app';
|
||||
import {
|
||||
cpuMillicoresToQuantity,
|
||||
memoryMiToQuantity,
|
||||
storageGiToQuantity
|
||||
} from '@/utils/resourceQuantity';
|
||||
|
||||
const createApp = (customDomain = '', openNodePort = false): AppEditType =>
|
||||
({
|
||||
appName: 'demo',
|
||||
imageName: 'nginx:latest',
|
||||
runCMD: '',
|
||||
cmdParam: '',
|
||||
replicas: 1,
|
||||
cpu: cpuMillicoresToQuantity(100),
|
||||
memory: memoryMiToQuantity(128),
|
||||
networks: [
|
||||
{
|
||||
networkName: 'demo-web',
|
||||
portName: 'web',
|
||||
port: 80,
|
||||
protocol: 'TCP',
|
||||
appProtocol: 'HTTP',
|
||||
openPublicDomain: true,
|
||||
publicDomain: 'demo',
|
||||
customDomain,
|
||||
domain: 'cloud.example.com',
|
||||
openNodePort
|
||||
}
|
||||
],
|
||||
envs: [],
|
||||
hpa: {
|
||||
use: false,
|
||||
target: 'cpu',
|
||||
value: 50,
|
||||
minReplicas: 1,
|
||||
maxReplicas: 1
|
||||
},
|
||||
secret: {
|
||||
use: false,
|
||||
username: '',
|
||||
password: '',
|
||||
serverAddress: ''
|
||||
},
|
||||
configMapList: [],
|
||||
storeList: [],
|
||||
labels: {},
|
||||
volumes: [],
|
||||
volumeMounts: [],
|
||||
kind: 'deployment'
|
||||
} as AppEditType);
|
||||
|
||||
describe('json2Ingress', () => {
|
||||
const userDomains = [{ name: 'cloud.example.com', secretName: 'wildcard-cert' }];
|
||||
|
||||
it('keeps tls and custom domain cert-manager resources in https mode', () => {
|
||||
const objects = yamlString2Objects(
|
||||
json2Ingress(createApp('custom.example.com'), userDomains, {
|
||||
disableHttps: false
|
||||
})
|
||||
) as any[];
|
||||
|
||||
expect(objects.map((item) => item.kind)).toEqual(['Ingress', 'Issuer', 'Certificate']);
|
||||
expect(objects[0].spec.tls).toEqual([
|
||||
{
|
||||
hosts: ['custom.example.com'],
|
||||
secretName: 'demo-web'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits tls, ssl redirect annotations, and cert-manager resources in http-only mode', () => {
|
||||
const objects = yamlString2Objects(
|
||||
json2Ingress(createApp('custom.example.com'), userDomains, {
|
||||
disableHttps: true
|
||||
})
|
||||
) as any[];
|
||||
|
||||
expect(objects.map((item) => item.kind)).toEqual(['Ingress']);
|
||||
expect(objects[0].spec.tls).toBeUndefined();
|
||||
expect(
|
||||
objects[0].metadata.annotations['nginx.ingress.kubernetes.io/ssl-redirect']
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('json2DeployCr', () => {
|
||||
it('emits Quantity CPU and memory limits with 10 percent requests', () => {
|
||||
const objects = yamlString2Objects(
|
||||
json2DeployCr(
|
||||
{
|
||||
...createApp(),
|
||||
cpu: cpuMillicoresToQuantity(500),
|
||||
memory: memoryMiToQuantity(1024)
|
||||
},
|
||||
'deployment'
|
||||
)
|
||||
) as any[];
|
||||
|
||||
const resources = objects[0].spec.template.spec.containers[0].resources;
|
||||
|
||||
expect(resources.limits.cpu).toBe('500m');
|
||||
expect(resources.requests.cpu).toBe('50m');
|
||||
expect(resources.limits.memory).toBe('1Gi');
|
||||
expect(resources.requests.memory).toBe('102Mi');
|
||||
});
|
||||
|
||||
it('emits PVC storage from Quantity values', () => {
|
||||
const objects = yamlString2Objects(
|
||||
json2DeployCr(
|
||||
{
|
||||
...createApp(),
|
||||
storeList: [
|
||||
{
|
||||
name: 'data',
|
||||
path: '/data',
|
||||
value: storageGiToQuantity(2)
|
||||
}
|
||||
],
|
||||
kind: 'statefulset'
|
||||
},
|
||||
'statefulset'
|
||||
)
|
||||
) as any[];
|
||||
|
||||
const template = objects[0].spec.volumeClaimTemplates[0];
|
||||
|
||||
expect(template.metadata.annotations.value).toBe('2Gi');
|
||||
expect(template.spec.resources.requests.storage).toBe('2Gi');
|
||||
expect(objects[0].metadata.annotations[deployPVCResizeKey]).toBe('2Gi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('json2Service', () => {
|
||||
it('adds a 13-character suffix to generated service names', () => {
|
||||
const app = createApp();
|
||||
const objects = yamlString2Objects(json2Service(app)) as any[];
|
||||
const serviceName = objects[0].metadata.name;
|
||||
|
||||
expect(serviceName).toMatch(/^demo-[a-z]{12}$/);
|
||||
expect(serviceName.length - app.appName.length).toBe(13);
|
||||
});
|
||||
|
||||
it('adds a 22-character suffix to generated nodeport service names', () => {
|
||||
const app = createApp('', true);
|
||||
const objects = yamlString2Objects(json2Service(app)) as any[];
|
||||
const serviceName = objects[0].metadata.name;
|
||||
|
||||
expect(serviceName).toMatch(/^demo-nodeport-[a-z]{12}$/);
|
||||
expect(serviceName.length - app.appName.length).toBe(22);
|
||||
});
|
||||
|
||||
it('keeps the numeric-prefix app name invalidity visible before apply', () => {
|
||||
const app = createApp('', true);
|
||||
app.appName = '111111hello-world';
|
||||
|
||||
const objects = yamlString2Objects(json2Service(app)) as any[];
|
||||
|
||||
expect(objects[0].metadata.name).toMatch(/^111111hello-world-nodeport-[a-z]{12}$/);
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseDotenvEnvs, stringifyDotenvEnvs, type DotenvEnv } from '@/utils/dotenvEnv';
|
||||
|
||||
const toObject = (envs: DotenvEnv[]) =>
|
||||
envs.reduce<Record<string, string>>((acc, { key, value }) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const dotenvReferenceInput = [
|
||||
'BASIC=basic',
|
||||
'',
|
||||
'# previous line intentionally left blank',
|
||||
'AFTER_LINE=after_line',
|
||||
'EMPTY=',
|
||||
"EMPTY_SINGLE_QUOTES=''",
|
||||
'EMPTY_DOUBLE_QUOTES=""',
|
||||
'EMPTY_BACKTICKS=``',
|
||||
"SINGLE_QUOTES='single_quotes'",
|
||||
"SINGLE_QUOTES_SPACED=' single quotes '",
|
||||
'DOUBLE_QUOTES="double_quotes"',
|
||||
'DOUBLE_QUOTES_SPACED=" double quotes "',
|
||||
'DOUBLE_QUOTES_INSIDE_SINGLE=\'double "quotes" work inside single quotes\'',
|
||||
'SINGLE_QUOTES_INSIDE_DOUBLE="single \'quotes\' work inside double quotes"',
|
||||
'BACKTICKS=`backticks`',
|
||||
'BACKTICKS_SPACED=` backticks `',
|
||||
'DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS=`double "quotes" and single \'quotes\' work inside backticks`',
|
||||
'EXPAND_NEWLINES="expand\\nnew\\nlines"',
|
||||
'DONT_EXPAND_UNQUOTED=dontexpand\\nnewlines',
|
||||
"DONT_EXPAND_SQUOTED='dontexpand\\nnewlines'",
|
||||
'# COMMENTS=work',
|
||||
'INLINE_COMMENTS=inline comments # work #very #well',
|
||||
"INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work",
|
||||
'INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work',
|
||||
'INLINE_COMMENTS_BACKTICKS=`inline comments outside of #backticks` # work',
|
||||
'INLINE_COMMENTS_SPACE=inline comments start with a#number sign. no space required.',
|
||||
'EQUAL_SIGNS=equals==',
|
||||
'RETAIN_INNER_QUOTES={"foo": "bar"}',
|
||||
'RETAIN_INNER_QUOTES_AS_STRING=\'{"foo": "bar"}\'',
|
||||
'RETAIN_INNER_QUOTES_AS_BACKTICKS=`{"foo": "bar\'s"}`',
|
||||
'TRIM_SPACE_FROM_UNQUOTED= some spaced out string',
|
||||
'USERNAME=therealnerdybeast@example.tld',
|
||||
' SPACED_KEY = parsed',
|
||||
'export EXPORT_IS_DECLARED=parsed',
|
||||
'export EXPORT_IS_DECLARED_WITH_SPACING=parsed',
|
||||
'export EXPORT_IS_DECLARED_WITH_SOME_VALUE=some_value',
|
||||
'export EXPORT_IS_DECLARED_WITH_SOME_VALUE_SPACED=some_value',
|
||||
'export EXPORT_IS_DECLARED_WITH_SOME_VALUE_AND_SPACING =some_value'
|
||||
].join('\n');
|
||||
|
||||
const multilineReferenceInput = [
|
||||
'MULTI_DOUBLE_QUOTED="THIS',
|
||||
'IS',
|
||||
'A',
|
||||
'MULTILINE',
|
||||
'STRING"',
|
||||
'',
|
||||
"MULTI_SINGLE_QUOTED='THIS",
|
||||
'IS',
|
||||
'A',
|
||||
'MULTILINE',
|
||||
"STRING'",
|
||||
'',
|
||||
'MULTI_BACKTICKED=`THIS',
|
||||
'IS',
|
||||
'A',
|
||||
'"MULTILINE\'S"',
|
||||
'STRING`',
|
||||
'',
|
||||
'MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY-----',
|
||||
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u',
|
||||
'LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/',
|
||||
'-----END PUBLIC KEY-----"'
|
||||
].join('\n');
|
||||
|
||||
describe('dotenv environment variable parser', () => {
|
||||
describe('dotenv-compatible parsing', () => {
|
||||
const parsed = toObject(parseDotenvEnvs(dotenvReferenceInput));
|
||||
|
||||
it('parses basic values, blank lines, and empty quoted values', () => {
|
||||
expect(parsed.BASIC).toBe('basic');
|
||||
expect(parsed.AFTER_LINE).toBe('after_line');
|
||||
expect(parsed.EMPTY).toBe('');
|
||||
expect(parsed.EMPTY_SINGLE_QUOTES).toBe('');
|
||||
expect(parsed.EMPTY_DOUBLE_QUOTES).toBe('');
|
||||
expect(parsed.EMPTY_BACKTICKS).toBe('');
|
||||
expect(parsed.COMMENTS).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes surrounding quotes while preserving inner spacing and quotes', () => {
|
||||
expect(parsed.SINGLE_QUOTES).toBe('single_quotes');
|
||||
expect(parsed.SINGLE_QUOTES_SPACED).toBe(' single quotes ');
|
||||
expect(parsed.DOUBLE_QUOTES).toBe('double_quotes');
|
||||
expect(parsed.DOUBLE_QUOTES_SPACED).toBe(' double quotes ');
|
||||
expect(parsed.BACKTICKS).toBe('backticks');
|
||||
expect(parsed.BACKTICKS_SPACED).toBe(' backticks ');
|
||||
expect(parsed.DOUBLE_QUOTES_INSIDE_SINGLE).toBe('double "quotes" work inside single quotes');
|
||||
expect(parsed.SINGLE_QUOTES_INSIDE_DOUBLE).toBe("single 'quotes' work inside double quotes");
|
||||
expect(parsed.DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS).toBe(
|
||||
'double "quotes" and single \'quotes\' work inside backticks'
|
||||
);
|
||||
expect(parsed.RETAIN_INNER_QUOTES).toBe('{"foo": "bar"}');
|
||||
expect(parsed.RETAIN_INNER_QUOTES_AS_STRING).toBe('{"foo": "bar"}');
|
||||
expect(parsed.RETAIN_INNER_QUOTES_AS_BACKTICKS).toBe('{"foo": "bar\'s"}');
|
||||
});
|
||||
|
||||
it('matches dotenv newline escape and comment behavior', () => {
|
||||
expect(parsed.EXPAND_NEWLINES).toBe('expand\nnew\nlines');
|
||||
expect(parsed.DONT_EXPAND_UNQUOTED).toBe('dontexpand\\nnewlines');
|
||||
expect(parsed.DONT_EXPAND_SQUOTED).toBe('dontexpand\\nnewlines');
|
||||
expect(parsed.INLINE_COMMENTS).toBe('inline comments');
|
||||
expect(parsed.INLINE_COMMENTS_SINGLE_QUOTES).toBe('inline comments outside of #singlequotes');
|
||||
expect(parsed.INLINE_COMMENTS_DOUBLE_QUOTES).toBe('inline comments outside of #doublequotes');
|
||||
expect(parsed.INLINE_COMMENTS_BACKTICKS).toBe('inline comments outside of #backticks');
|
||||
expect(parsed.INLINE_COMMENTS_SPACE).toBe('inline comments start with a');
|
||||
});
|
||||
|
||||
it('keeps values with equals signs, trims unquoted values, and supports export', () => {
|
||||
expect(parsed.EQUAL_SIGNS).toBe('equals==');
|
||||
expect(parsed.TRIM_SPACE_FROM_UNQUOTED).toBe('some spaced out string');
|
||||
expect(parsed.USERNAME).toBe('therealnerdybeast@example.tld');
|
||||
expect(parsed.SPACED_KEY).toBe('parsed');
|
||||
expect(parsed.EXPORT_IS_DECLARED).toBe('parsed');
|
||||
expect(parsed.EXPORT_IS_DECLARED_WITH_SPACING).toBe('parsed');
|
||||
expect(parsed.EXPORT_IS_DECLARED_WITH_SOME_VALUE).toBe('some_value');
|
||||
expect(parsed.EXPORT_IS_DECLARED_WITH_SOME_VALUE_SPACED).toBe('some_value');
|
||||
expect(parsed.EXPORT_IS_DECLARED_WITH_SOME_VALUE_AND_SPACING).toBe('some_value');
|
||||
});
|
||||
|
||||
it('normalizes dotenv-supported line endings', () => {
|
||||
const expected = { SERVER: 'localhost', PASSWORD: 'password', DB: 'tests' };
|
||||
|
||||
expect(toObject(parseDotenvEnvs('SERVER=localhost\rPASSWORD=password\rDB=tests\r'))).toEqual(
|
||||
expected
|
||||
);
|
||||
expect(toObject(parseDotenvEnvs('SERVER=localhost\nPASSWORD=password\nDB=tests\n'))).toEqual(
|
||||
expected
|
||||
);
|
||||
expect(
|
||||
toObject(parseDotenvEnvs('SERVER=localhost\r\nPASSWORD=password\r\nDB=tests\r\n'))
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it('parses multiline quoted values from dotenv reference cases', () => {
|
||||
const multiline = toObject(parseDotenvEnvs(multilineReferenceInput));
|
||||
const multiPem = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u
|
||||
LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
expect(multiline.MULTI_DOUBLE_QUOTED).toBe('THIS\nIS\nA\nMULTILINE\nSTRING');
|
||||
expect(multiline.MULTI_SINGLE_QUOTED).toBe('THIS\nIS\nA\nMULTILINE\nSTRING');
|
||||
expect(multiline.MULTI_BACKTICKED).toBe('THIS\nIS\nA\n"MULTILINE\'S"\nSTRING');
|
||||
expect(multiline.MULTI_PEM_DOUBLE_QUOTED).toBe(multiPem);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applaunchpad editor compatibility', () => {
|
||||
it('keeps existing editor shorthand forms compatible', () => {
|
||||
expect(
|
||||
parseDotenvEnvs(`
|
||||
mongoUrl=127.0.0.1:8000
|
||||
redisUrl:127.0.0.0:8001
|
||||
- env1 =test
|
||||
'QUOTED_KEY'=quoted-key
|
||||
`)
|
||||
).toEqual([
|
||||
{ key: 'mongoUrl', value: '127.0.0.1:8000' },
|
||||
{ key: 'redisUrl', value: '127.0.0.0:8001' },
|
||||
{ key: 'env1', value: 'test' },
|
||||
{ key: 'QUOTED_KEY', value: 'quoted-key' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves duplicate keys as separate form entries', () => {
|
||||
expect(parseDotenvEnvs('DUP=one\nDUP=two')).toEqual([
|
||||
{ key: 'DUP', value: 'one' },
|
||||
{ key: 'DUP', value: 'two' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes multiline values with dotenv-compatible quotes', () => {
|
||||
expect(
|
||||
stringifyDotenvEnvs([
|
||||
{ key: 'PLAIN', value: 'value' },
|
||||
{ key: 'MULTI', value: 'line1\nline2' },
|
||||
{ key: 'HASH', value: 'value # with hash' }
|
||||
])
|
||||
).toBe(`PLAIN=value
|
||||
MULTI='line1
|
||||
line2'
|
||||
HASH='value # with hash'`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildExternalUrl, formatPort, getExternalProtocol } from '@/utils/network-url';
|
||||
|
||||
describe('network-url helpers', () => {
|
||||
it('formats optional ports with one leading colon', () => {
|
||||
expect(formatPort('')).toBe('');
|
||||
expect(formatPort('8443')).toBe(':8443');
|
||||
expect(formatPort(':8080')).toBe(':8080');
|
||||
});
|
||||
|
||||
it('uses secure application protocols by default', () => {
|
||||
expect(getExternalProtocol('HTTP')).toBe('https');
|
||||
expect(getExternalProtocol('GRPC')).toBe('grpcs');
|
||||
expect(getExternalProtocol('WS')).toBe('wss');
|
||||
});
|
||||
|
||||
it('uses plain application protocols when https is disabled', () => {
|
||||
const config = { disableHttps: true };
|
||||
|
||||
expect(getExternalProtocol('HTTP', config)).toBe('http');
|
||||
expect(getExternalProtocol('GRPC', config)).toBe('grpc');
|
||||
expect(getExternalProtocol('WS', config)).toBe('ws');
|
||||
});
|
||||
|
||||
it('keeps transport protocols independent from https mode', () => {
|
||||
const config = { disableHttps: true, httpPort: '80', cloudPort: '443' };
|
||||
|
||||
expect(
|
||||
buildExternalUrl({ protocol: 'TCP', host: 'tcp.example.com', nodePort: 30001, config })
|
||||
).toBe('tcp://tcp.example.com:30001');
|
||||
expect(
|
||||
buildExternalUrl({ protocol: 'UDP', host: 'udp.example.com', nodePort: 30002, config })
|
||||
).toBe('udp://udp.example.com:30002');
|
||||
});
|
||||
|
||||
it('builds http-only public application URLs with the http port', () => {
|
||||
const config = { disableHttps: true, httpPort: ':80', cloudPort: ':443' };
|
||||
|
||||
expect(buildExternalUrl({ protocol: 'HTTP', host: 'app.example.com', config })).toBe(
|
||||
'http://app.example.com:80'
|
||||
);
|
||||
expect(buildExternalUrl({ protocol: 'GRPC', host: 'grpc.example.com', config })).toBe(
|
||||
'grpc://grpc.example.com:80'
|
||||
);
|
||||
expect(buildExternalUrl({ protocol: 'WS', host: 'ws.example.com', config })).toBe(
|
||||
`ws${'://'}ws.example.com:80`
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the default secure public port', () => {
|
||||
const config = { disableHttps: false, httpPort: ':80', cloudPort: ':443' };
|
||||
|
||||
expect(buildExternalUrl({ protocol: 'HTTP', host: 'app.example.com', config })).toBe(
|
||||
'https://app.example.com'
|
||||
);
|
||||
expect(buildExternalUrl({ protocol: 'GRPC', host: 'grpc.example.com', config })).toBe(
|
||||
'grpcs://grpc.example.com'
|
||||
);
|
||||
expect(buildExternalUrl({ protocol: 'WS', host: 'ws.example.com', config })).toBe(
|
||||
`wss${'://'}ws.example.com`
|
||||
);
|
||||
expect(
|
||||
buildExternalUrl({ protocol: 'TCP', host: 'tcp.example.com', nodePort: 443, config })
|
||||
).toBe('tcp://tcp.example.com:443');
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { V1Pod } from '@kubernetes/client-node';
|
||||
|
||||
import { getPodContainerName } from '@/utils/pod';
|
||||
|
||||
describe('getPodContainerName', () => {
|
||||
it('uses the first container from the pod spec', () => {
|
||||
const pod = {
|
||||
spec: {
|
||||
containers: [
|
||||
{ name: 'real-container', image: 'nginx' },
|
||||
{ name: 'sidecar', image: 'busybox' }
|
||||
]
|
||||
},
|
||||
status: {
|
||||
containerStatuses: [{ name: 'status-container' }]
|
||||
}
|
||||
} as V1Pod;
|
||||
|
||||
expect(getPodContainerName(pod)).toBe('real-container');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatResourceQuotaValue } from '@sealos/shared';
|
||||
|
||||
describe('formatResourceQuotaValue', () => {
|
||||
it('formats the pod quota exposed by the desktop quota API', () => {
|
||||
expect(formatResourceQuotaValue(2, 'pod')).toBe('2.00');
|
||||
});
|
||||
|
||||
it('does not crash when a newer quota type reaches the UI', () => {
|
||||
const unknownType = 'unknown-resource' as Parameters<typeof formatResourceQuotaValue>[1];
|
||||
|
||||
expect(formatResourceQuotaValue(1024, unknownType)).toBe('--');
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Quantity } from '@sealos/shared';
|
||||
import {
|
||||
cpuMillicoresToQuantity,
|
||||
parseK8sQuantityOrZero,
|
||||
publicCpuCoresToQuantity,
|
||||
publicMemoryGiToQuantity,
|
||||
quantityToCpuMillicores,
|
||||
quantityToMemoryMi,
|
||||
quantityToPublicCpuCores,
|
||||
quantityToPublicMemoryGi,
|
||||
quantityToStorageGi,
|
||||
storageGiToQuantity,
|
||||
memoryMiToQuantity,
|
||||
storageAnnotationToQuantity
|
||||
} from '@/utils/resourceQuantity';
|
||||
|
||||
describe('resourceQuantity helpers', () => {
|
||||
it('converts public CPU cores to Quantity and back', () => {
|
||||
const quantity = publicCpuCoresToQuantity(0.5);
|
||||
|
||||
expect(quantity.toString()).toBe('500m');
|
||||
expect(quantityToPublicCpuCores(quantity)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('converts CPU millicores to Quantity and back', () => {
|
||||
const quantity = cpuMillicoresToQuantity(200);
|
||||
|
||||
expect(quantity.toString()).toBe('200m');
|
||||
expect(quantityToCpuMillicores(quantity)).toBe(200);
|
||||
});
|
||||
|
||||
it('converts public memory Gi values to BinarySI Quantity and back', () => {
|
||||
const quantity = publicMemoryGiToQuantity(1);
|
||||
|
||||
expect(quantity.toString()).toBe('1Gi');
|
||||
expect(quantityToPublicMemoryGi(quantity)).toBe(1);
|
||||
});
|
||||
|
||||
it('converts memory Mi values to Quantity and back', () => {
|
||||
const quantity = memoryMiToQuantity(256);
|
||||
|
||||
expect(quantity.toString()).toBe('256Mi');
|
||||
expect(quantityToMemoryMi(quantity)).toBe(256);
|
||||
});
|
||||
|
||||
it('converts fractional public memory values through Mi precision', () => {
|
||||
const quantity = publicMemoryGiToQuantity(0.5);
|
||||
|
||||
expect(quantity.toString()).toBe('512Mi');
|
||||
expect(quantityToPublicMemoryGi(quantity)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('returns zero for empty, missing, or invalid Kubernetes quantities', () => {
|
||||
expect(parseK8sQuantityOrZero(undefined).equals(Quantity.ZERO)).toBe(true);
|
||||
expect(parseK8sQuantityOrZero('').equals(Quantity.ZERO)).toBe(true);
|
||||
expect(parseK8sQuantityOrZero('invalid').equals(Quantity.ZERO)).toBe(true);
|
||||
});
|
||||
|
||||
it('converts storage Gi values to Quantity and back', () => {
|
||||
const quantity = storageGiToQuantity(2);
|
||||
|
||||
expect(quantity.toString()).toBe('2Gi');
|
||||
expect(quantityToStorageGi(quantity)).toBe(2);
|
||||
});
|
||||
|
||||
it('parses legacy and Quantity PVC storage annotations', () => {
|
||||
expect(storageAnnotationToQuantity('2').toString()).toBe('2Gi');
|
||||
expect(storageAnnotationToQuantity('2Gi').toString()).toBe('2Gi');
|
||||
expect(
|
||||
storageAnnotationToQuantity('1536Mi').formatForDisplay({
|
||||
format: 'BinarySI',
|
||||
scale: 'auto',
|
||||
digits: 4
|
||||
})
|
||||
).toBe('1.5Gi');
|
||||
expect(storageAnnotationToQuantity(undefined).equals(Quantity.ZERO)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Quantity } from '@sealos/shared';
|
||||
import {
|
||||
calculateStorageUsagePercent,
|
||||
calculateStorageUsagePercentFromUsageData
|
||||
} from '@/utils/storage-usage';
|
||||
|
||||
describe('storage usage helpers', () => {
|
||||
it('calculates storage usage from total used bytes over total size bytes', () => {
|
||||
const sizeData = [
|
||||
{ name: 'pvc-a', xData: [100, 200], yData: ['100', '100'] },
|
||||
{ name: 'pvc-b', xData: [100, 200], yData: ['900', '900'] }
|
||||
];
|
||||
const availData = [
|
||||
{ name: 'pvc-a', xData: [100, 200], yData: ['0', '0'] },
|
||||
{ name: 'pvc-b', xData: [100, 200], yData: ['810', '810'] }
|
||||
];
|
||||
|
||||
expect(calculateStorageUsagePercent(sizeData, availData)).toBe(19);
|
||||
});
|
||||
|
||||
it('returns zero when size data has no usable positive values', () => {
|
||||
expect(
|
||||
calculateStorageUsagePercent(
|
||||
[{ name: 'pvc-a', xData: [100], yData: ['0'] }],
|
||||
[{ name: 'pvc-a', xData: [100], yData: ['0'] }]
|
||||
)
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps tiny non-zero usage visible', () => {
|
||||
expect(
|
||||
calculateStorageUsagePercent(
|
||||
[{ name: 'pvc-a', xData: [100], yData: ['1000'] }],
|
||||
[{ name: 'pvc-a', xData: [100], yData: ['999.5'] }]
|
||||
)
|
||||
).toBe(0.1);
|
||||
});
|
||||
|
||||
it('calculates app storage usage from per-PVC usage percentages', () => {
|
||||
const usageData = [
|
||||
{ name: 'data-demo-0', xData: [100, 200], yData: ['10', '25'] },
|
||||
{ name: 'cache-demo-0', xData: [100, 200], yData: ['20', '50'] }
|
||||
];
|
||||
const storeList = [
|
||||
{ name: 'data', value: { formatForDisplay: () => '100' } },
|
||||
{ name: 'cache', value: { formatForDisplay: () => '300' } }
|
||||
];
|
||||
|
||||
expect(calculateStorageUsagePercentFromUsageData(usageData, storeList)).toBe(43.8);
|
||||
});
|
||||
|
||||
it('falls back to equal weighting when configured capacities are unavailable', () => {
|
||||
const usageData = [
|
||||
{ name: 'data-demo-0', xData: [100], yData: ['10'] },
|
||||
{ name: 'cache-demo-0', xData: [100], yData: ['50'] }
|
||||
];
|
||||
|
||||
expect(calculateStorageUsagePercentFromUsageData(usageData)).toBe(30);
|
||||
});
|
||||
|
||||
it('uses real Quantity values for configured storage capacities', () => {
|
||||
const usageData = [
|
||||
{ name: 'data-demo-0', xData: [100], yData: ['20'] },
|
||||
{ name: 'cache-demo-0', xData: [100], yData: ['60'] }
|
||||
];
|
||||
const storeList = [
|
||||
{ name: 'data', value: Quantity.mustParse('1Gi') },
|
||||
{ name: 'cache', value: Quantity.mustParse('3Gi') }
|
||||
];
|
||||
|
||||
expect(calculateStorageUsagePercentFromUsageData(usageData, storeList)).toBe(50);
|
||||
});
|
||||
|
||||
it('counts each configured store capacity once across replica PVCs', () => {
|
||||
const usageData = [
|
||||
{ name: 'vn-homevn-fulling-test-jopywbsx-0', xData: [100], yData: ['51.97'] },
|
||||
{ name: 'vn-homevn-fulling-test-jopywbsx-1', xData: [100], yData: ['0.02'] },
|
||||
{ name: 'vn-homevn-fulling-test-jopywbsx-2', xData: [100], yData: ['0.02'] },
|
||||
{ name: 'vn-homevn-data1-test-jopywbsx-0', xData: [100], yData: ['6.60'] },
|
||||
{ name: 'vn-homevn-data1-test-jopywbsx-1', xData: [100], yData: ['0.03'] },
|
||||
{ name: 'vn-homevn-data2-test-jopywbsx-0', xData: [100], yData: ['1.06'] },
|
||||
{ name: 'vn-homevn-data3-test-jopywbsx-0', xData: [100], yData: ['0.03'] },
|
||||
{ name: 'vn-homevn-data-test-jopywbsx-0', xData: [100], yData: ['0.03'] }
|
||||
];
|
||||
const storeList = [
|
||||
{ name: 'vn-homevn-fulling', value: Quantity.mustParse('10Gi') },
|
||||
{ name: 'vn-homevn-data1', value: Quantity.mustParse('1Gi') },
|
||||
{ name: 'vn-homevn-data2', value: Quantity.mustParse('1Gi') },
|
||||
{ name: 'vn-homevn-data3', value: Quantity.mustParse('1Gi') },
|
||||
{ name: 'vn-homevn-data', value: Quantity.mustParse('1Gi') }
|
||||
];
|
||||
|
||||
expect(calculateStorageUsagePercentFromUsageData(usageData, storeList)).toBe(37.7);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatDateTimeInTimeZone,
|
||||
getBrowserTimeZone,
|
||||
getBoundedRangeStart,
|
||||
getBoundedDayRangeInTimeZone,
|
||||
getDayBoundsInTimeZone,
|
||||
getUtcTimestamp,
|
||||
normalizeTimeInput,
|
||||
orderDateRange,
|
||||
parseDateTimeInTimeZone
|
||||
} from '@/utils/timeRange';
|
||||
|
||||
describe('time zone date time helpers', () => {
|
||||
const shanghaiTimeZone = 'Asia/Shanghai';
|
||||
|
||||
it('uses UTC as the server-rendering fallback', () => {
|
||||
expect(getBrowserTimeZone()).toBe('UTC');
|
||||
});
|
||||
|
||||
it('parses local HH:mm input in the selected time zone', () => {
|
||||
expect(parseDateTimeInTimeZone('2026-07-28', '00:00', shanghaiTimeZone)?.toISOString()).toBe(
|
||||
'2026-07-27T16:00:00.000Z'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts the same wall-clock time differently for each user time zone', () => {
|
||||
const start = parseDateTimeInTimeZone('2026-07-28', '10:00', shanghaiTimeZone);
|
||||
const end = parseDateTimeInTimeZone('2026-07-28', '10:00', 'America/Los_Angeles');
|
||||
|
||||
expect(start?.toISOString()).toBe('2026-07-28T02:00:00.000Z');
|
||||
expect(end?.toISOString()).toBe('2026-07-28T17:00:00.000Z');
|
||||
});
|
||||
|
||||
it('formats instants in the selected user time zone', () => {
|
||||
expect(formatDateTimeInTimeZone('2026-07-27T12:00:00.000Z', shanghaiTimeZone)).toBe(
|
||||
'2026-07-27 20:00:00'
|
||||
);
|
||||
expect(formatDateTimeInTimeZone('2026-07-27T12:00:00.000Z', 'America/Los_Angeles')).toBe(
|
||||
'2026-07-27 05:00:00'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats backend timestamps without a timezone suffix as UTC', () => {
|
||||
expect(formatDateTimeInTimeZone('2026-07-27 12:00:00', shanghaiTimeZone)).toBe(
|
||||
'2026-07-27 20:00:00'
|
||||
);
|
||||
expect(getUtcTimestamp('2026-07-27 12:00:00')).toBe(
|
||||
new Date('2026-07-27T12:00:00.000Z').getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes short time input without changing full time input', () => {
|
||||
expect(normalizeTimeInput('12:00')).toBe('12:00:00');
|
||||
expect(normalizeTimeInput('12:00:30')).toBe('12:00:30');
|
||||
});
|
||||
|
||||
it('rejects invalid exact times', () => {
|
||||
expect(parseDateTimeInTimeZone('2026-07-27', '25:00', shanghaiTimeZone)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects nonexistent local times during DST spring-forward', () => {
|
||||
expect(parseDateTimeInTimeZone('2026-03-08', '02:30', 'America/Los_Angeles')).toBeNull();
|
||||
});
|
||||
|
||||
it('derives calendar day bounds in the selected time zone', () => {
|
||||
const bounds = getDayBoundsInTimeZone(new Date('2026-07-27T17:00:00.000Z'), shanghaiTimeZone);
|
||||
|
||||
expect(bounds.start.toISOString()).toBe('2026-07-27T16:00:00.000Z');
|
||||
expect(bounds.end.toISOString()).toBe('2026-07-28T15:59:59.999Z');
|
||||
});
|
||||
|
||||
it('clamps calendar days to the exact seven-day and current-time boundaries', () => {
|
||||
const min = new Date('2026-07-21T04:00:00.000Z');
|
||||
const max = new Date('2026-07-28T04:00:00.000Z');
|
||||
const range = getBoundedDayRangeInTimeZone(
|
||||
new Date('2026-07-20T16:00:00.000Z'),
|
||||
new Date('2026-07-27T16:00:00.000Z'),
|
||||
min,
|
||||
max,
|
||||
shanghaiTimeZone
|
||||
);
|
||||
|
||||
expect(range.from?.toISOString()).toBe(min.toISOString());
|
||||
expect(range.to?.toISOString()).toBe(max.toISOString());
|
||||
});
|
||||
|
||||
it('keeps the earliest selectable day usable as the seven-day boundary advances', () => {
|
||||
const min = new Date('2026-07-21T04:00:00.500Z');
|
||||
const start = new Date('2026-07-21T04:00:00.000Z');
|
||||
const end = new Date('2026-07-21T05:00:00.000Z');
|
||||
|
||||
expect(getBoundedRangeStart(start, end, min, shanghaiTimeZone)).toEqual(min);
|
||||
});
|
||||
|
||||
it('rejects ranges that are entirely before the seven-day boundary', () => {
|
||||
const min = new Date('2026-07-21T04:00:00.000Z');
|
||||
const start = new Date('2026-07-21T02:00:00.000Z');
|
||||
const end = new Date('2026-07-21T03:00:00.000Z');
|
||||
|
||||
expect(getBoundedRangeStart(start, end, min, shanghaiTimeZone)).toBeNull();
|
||||
});
|
||||
|
||||
it('orders reversed manual range endpoints', () => {
|
||||
const later = new Date('2026-07-28T04:00:00.000Z');
|
||||
const earlier = new Date('2026-07-28T02:00:00.000Z');
|
||||
|
||||
expect(orderDateRange(later, earlier)).toEqual({ from: earlier, to: later });
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
import { patchYamlList } from '@/utils/tools';
|
||||
|
||||
const createFormDeployment = (image: string) => ({
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
metadata: {
|
||||
name: 'litellm-demo'
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: 'litellm-demo'
|
||||
}
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: 'litellm-demo'
|
||||
}
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'litellm-demo',
|
||||
image,
|
||||
volumeMounts: []
|
||||
}
|
||||
],
|
||||
volumes: []
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('patchYamlList', () => {
|
||||
it('preserves volumes referenced only by init containers from the source deployment', () => {
|
||||
const originalDeployment = {
|
||||
...createFormDeployment('litellm/litellm-database:v1.88.1'),
|
||||
metadata: {
|
||||
name: 'litellm-demo',
|
||||
namespace: 'ns-demo'
|
||||
},
|
||||
spec: {
|
||||
...createFormDeployment('litellm/litellm-database:v1.88.1').spec,
|
||||
template: {
|
||||
...createFormDeployment('litellm/litellm-database:v1.88.1').spec.template,
|
||||
spec: {
|
||||
...createFormDeployment('litellm/litellm-database:v1.88.1').spec.template.spec,
|
||||
initContainers: [
|
||||
{
|
||||
name: 'init-s3-config',
|
||||
image: 'minio/mc:latest',
|
||||
volumeMounts: [
|
||||
{
|
||||
name: 'litellm-demo-cm',
|
||||
mountPath: '/tmp/init-s3-config.sh',
|
||||
subPath: 'init-s3-config.sh'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
volumes: [
|
||||
{
|
||||
name: 'litellm-demo-cm',
|
||||
configMap: {
|
||||
name: 'litellm-demo'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const actions = patchYamlList({
|
||||
parsedOldYamlList: [yaml.dump(createFormDeployment('litellm/litellm-database:v1.88.1'))],
|
||||
parsedNewYamlList: [yaml.dump(createFormDeployment('litellm/litellm-database:v1.88.2'))],
|
||||
originalYamlList: [originalDeployment as any]
|
||||
});
|
||||
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toMatchObject({
|
||||
type: 'patch',
|
||||
kind: 'Deployment'
|
||||
});
|
||||
|
||||
const patchedDeployment = actions[0].type === 'patch' ? actions[0].value : undefined;
|
||||
|
||||
expect(patchedDeployment?.spec.template.spec.initContainers[0].volumeMounts[0].name).toBe(
|
||||
'litellm-demo-cm'
|
||||
);
|
||||
expect(patchedDeployment?.spec.template.spec.volumes).toContainEqual({
|
||||
name: 'litellm-demo-cm',
|
||||
configMap: {
|
||||
name: 'litellm-demo'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
# yaml-language-server: $schema=config.schema.json
|
||||
cloud:
|
||||
domain: cloud.example.io
|
||||
port: 443
|
||||
httpPort: 80
|
||||
disableHttps: false
|
||||
desktopDomain: cloud.example.io
|
||||
userDomains:
|
||||
- name: cloud.example.io
|
||||
secretName: wildcard-cert
|
||||
|
||||
launchpad:
|
||||
infrastructure:
|
||||
provider: 'alibaba'
|
||||
requiresIcpReg: false
|
||||
icpRegQueryLink: 'https://beian.miit.gov.cn/#/Integrated/index'
|
||||
domainBindingDocumentationLink: null
|
||||
|
||||
ui:
|
||||
currencySymbol: shellCoin
|
||||
meta:
|
||||
title: 'Sealos App Launchpad'
|
||||
description: 'Deploy and manage your applications on Sealos'
|
||||
scripts: []
|
||||
appResourceFormSliderConfig:
|
||||
default:
|
||||
cpu: [100, 200, 500, 1000, 2000, 3000, 4000, 8000]
|
||||
memory: [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384]
|
||||
|
||||
components:
|
||||
monitoring:
|
||||
url: http://launchpad-monitor.sealos.svc.cluster.local:8428
|
||||
billing:
|
||||
url: http://account-service.account-system.svc:2333
|
||||
logging:
|
||||
enabled: true
|
||||
url: http://log-service.sealos.svc.cluster.local:8080
|
||||
eventAnalysis:
|
||||
enabled: false
|
||||
url: https://fastgpt.run/api/openapi/v1
|
||||
fastGPTKey: ''
|
||||
|
||||
features:
|
||||
guide: false
|
||||
api: false
|
||||
gpu: false
|
||||
|
||||
checkIcpReg:
|
||||
enabled: false
|
||||
endpoint: ''
|
||||
accessKeyID: ''
|
||||
accessKeySecret: ''
|
||||
|
||||
analytics:
|
||||
gtm:
|
||||
enabled: false
|
||||
gtmId: ''
|
||||
rybbit:
|
||||
host: ''
|
||||
siteId: ''
|
||||
|
||||
fileManager:
|
||||
uploadLimit: 50
|
||||
downloadLimit: 100
|
||||
|
||||
domainChallengeSecret: default-dev-secret-change-in-production
|
||||
pvcStorageMax: 100
|
||||
@@ -1,15 +0,0 @@
|
||||
FROM scratch
|
||||
|
||||
USER 65532:65532
|
||||
|
||||
COPY registry registry
|
||||
COPY charts charts
|
||||
COPY applaunchpad-frontend-entrypoint.sh applaunchpad-frontend-entrypoint.sh
|
||||
|
||||
ENV certSecretName="wildcard-cert"
|
||||
|
||||
ENV monitorUrl="http://launchpad-monitor.sealos.svc.cluster.local:8428"
|
||||
ENV billingUrl="http://account-service.account-system.svc:2333"
|
||||
ENV logUrl=""
|
||||
|
||||
CMD ["bash applaunchpad-frontend-entrypoint.sh"]
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
RELEASE_NAME=${RELEASE_NAME:-"applaunchpad-frontend"}
|
||||
RELEASE_NAMESPACE=${RELEASE_NAMESPACE:-"applaunchpad-frontend"}
|
||||
CHART_PATH=${CHART_PATH:-"./charts/applaunchpad-frontend"}
|
||||
HELM_OPTS=${HELM_OPTS:-""}
|
||||
|
||||
get_cm_value() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local key="$3"
|
||||
kubectl get configmap "${name}" -n "${namespace}" -o "jsonpath={.data.${key}}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
add_set_string() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
if [ -n "${value}" ]; then
|
||||
AUTO_CONFIG_HELM_OPTS="${AUTO_CONFIG_HELM_OPTS} --set-string ${key}=${value}"
|
||||
fi
|
||||
}
|
||||
|
||||
AUTO_CONFIG_HELM_OPTS=""
|
||||
|
||||
CONFIG_CLOUD_DOMAIN=$(get_cm_value sealos-system sealos-config cloudDomain)
|
||||
CONFIG_CLOUD_PORT=$(get_cm_value sealos-system sealos-config cloudPort)
|
||||
CONFIG_HTTP_PORT=$(get_cm_value sealos-system sealos-config httpPort)
|
||||
CONFIG_DISABLE_HTTPS=$(get_cm_value sealos-system sealos-config disableHttps)
|
||||
CONFIG_CERT_SECRET_NAME=$(get_cm_value sealos-system sealos-config certSecretName)
|
||||
|
||||
SEALOS_CLOUD_DOMAIN=${CONFIG_CLOUD_DOMAIN:-${SEALOS_CLOUD_DOMAIN:-${cloudDomain:-}}}
|
||||
SEALOS_CLOUD_PORT=${CONFIG_CLOUD_PORT:-${SEALOS_CLOUD_PORT:-${cloudPort:-}}}
|
||||
SEALOS_HTTP_PORT=${CONFIG_HTTP_PORT:-${SEALOS_HTTP_PORT:-${httpPort:-}}}
|
||||
SEALOS_DISABLE_HTTPS=${CONFIG_DISABLE_HTTPS:-${SEALOS_DISABLE_HTTPS:-${disableHttps:-}}}
|
||||
SEALOS_CERT_SECRET_NAME=${CONFIG_CERT_SECRET_NAME:-${SEALOS_CERT_SECRET_NAME:-${certSecretName:-}}}
|
||||
|
||||
add_set_string applaunchpadConfig.cloudDomain "${SEALOS_CLOUD_DOMAIN}"
|
||||
add_set_string applaunchpadConfig.cloudPort "${SEALOS_CLOUD_PORT}"
|
||||
add_set_string applaunchpadConfig.httpPort "${SEALOS_HTTP_PORT}"
|
||||
add_set_string applaunchpadConfig.disableHttps "${SEALOS_DISABLE_HTTPS}"
|
||||
add_set_string applaunchpadConfig.certSecretName "${SEALOS_CERT_SECRET_NAME}"
|
||||
add_set_string applaunchpadConfig.monitorUrl "${monitorUrl:-}"
|
||||
add_set_string applaunchpadConfig.billingUrl "${billingUrl:-}"
|
||||
add_set_string applaunchpadConfig.logUrl "${logUrl:-}"
|
||||
add_set_string applaunchpadConfig.tlsRejectUnauthorized "${tlsRejectUnauthorized:-}"
|
||||
|
||||
adopt_namespaced_resource() {
|
||||
local namespace="$1"
|
||||
local kind="$2"
|
||||
local name="$3"
|
||||
if kubectl -n "${namespace}" get "${kind}" "${name}" >/dev/null 2>&1; then
|
||||
echo "Adopting ${kind} ${namespace}/${name}..."
|
||||
kubectl -n "${namespace}" label "${kind}" "${name}" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null 2>&1 || true
|
||||
kubectl -n "${namespace}" annotate "${kind}" "${name}" meta.helm.sh/release-name="${RELEASE_NAME}" meta.helm.sh/release-namespace="${RELEASE_NAMESPACE}" --overwrite >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Checking and adopting existing resources..."
|
||||
if kubectl get namespace "${RELEASE_NAMESPACE}" >/dev/null 2>&1; then
|
||||
kubectl label namespace "${RELEASE_NAMESPACE}" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null 2>&1 || true
|
||||
kubectl annotate namespace "${RELEASE_NAMESPACE}" meta.helm.sh/release-name="${RELEASE_NAME}" meta.helm.sh/release-namespace="${RELEASE_NAMESPACE}" --overwrite >/dev/null 2>&1 || true
|
||||
|
||||
adopt_namespaced_resource "${RELEASE_NAMESPACE}" configmap applaunchpad-frontend-config
|
||||
adopt_namespaced_resource "${RELEASE_NAMESPACE}" deployment applaunchpad-frontend
|
||||
adopt_namespaced_resource "${RELEASE_NAMESPACE}" service applaunchpad-frontend
|
||||
adopt_namespaced_resource "${RELEASE_NAMESPACE}" ingress applaunchpad-frontend
|
||||
adopt_namespaced_resource "${RELEASE_NAMESPACE}" ingress applaunchpad-challenge
|
||||
fi
|
||||
|
||||
adopt_namespaced_resource app-system apps.app.sealos.io applaunchpad
|
||||
|
||||
SERVICE_NAME="applaunchpad-frontend"
|
||||
USER_VALUES_PATH="/root/.sealos/cloud/values/core/${SERVICE_NAME}-values.yaml"
|
||||
|
||||
if [ ! -f "${USER_VALUES_PATH}" ]; then
|
||||
mkdir -p "$(dirname "${USER_VALUES_PATH}")"
|
||||
cp "./charts/${SERVICE_NAME}/${SERVICE_NAME}-values.yaml" "${USER_VALUES_PATH}"
|
||||
fi
|
||||
|
||||
echo "Deploying Helm chart..."
|
||||
helm upgrade -i "${RELEASE_NAME}" -n "${RELEASE_NAMESPACE}" --create-namespace "${CHART_PATH}" \
|
||||
-f "./charts/${SERVICE_NAME}/values.yaml" \
|
||||
-f "${USER_VALUES_PATH}" \
|
||||
${AUTO_CONFIG_HELM_OPTS} \
|
||||
${HELM_OPTS}
|
||||
@@ -1,6 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: applaunchpad-frontend
|
||||
description: Sealos AppLaunchpad Frontend Helm Chart
|
||||
type: application
|
||||
version: 1.0.0
|
||||
appVersion: "latest"
|
||||
@@ -1,12 +0,0 @@
|
||||
# Custom values for applaunchpad frontend helm chart.
|
||||
# This file contains user-customizable configurations.
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2048Mi
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 128Mi
|
||||
@@ -1,111 +0,0 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "applaunchpad-frontend.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "applaunchpad-frontend.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "applaunchpad-frontend.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels.
|
||||
*/}}
|
||||
{{- define "applaunchpad-frontend.labels" -}}
|
||||
helm.sh/chart: {{ include "applaunchpad-frontend.chart" . }}
|
||||
{{ include "applaunchpad-frontend.selectorLabels" . }}
|
||||
{{ include "applaunchpad-frontend.recommendedLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels keep compatibility with the previous manifest selector.
|
||||
*/}}
|
||||
{{- define "applaunchpad-frontend.selectorLabels" -}}
|
||||
app: {{ include "applaunchpad-frontend.fullname" . }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Recommended Kubernetes labels.
|
||||
*/}}
|
||||
{{- define "applaunchpad-frontend.recommendedLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "applaunchpad-frontend.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.scheme" -}}
|
||||
{{- if eq (toString .Values.applaunchpadConfig.disableHttps) "true" -}}http{{- else -}}https{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.rawPort" -}}
|
||||
{{- $scheme := include "applaunchpad-frontend.scheme" . -}}
|
||||
{{- $port := toString .Values.applaunchpadConfig.cloudPort -}}
|
||||
{{- if eq $scheme "http" -}}
|
||||
{{- $port = toString .Values.applaunchpadConfig.httpPort -}}
|
||||
{{- end -}}
|
||||
{{- trimPrefix ":" $port -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.port" -}}
|
||||
{{- $scheme := include "applaunchpad-frontend.scheme" . -}}
|
||||
{{- $port := include "applaunchpad-frontend.rawPort" . -}}
|
||||
{{- if or (and (eq $scheme "https") (eq $port "443")) (and (eq $scheme "http") (eq $port "80")) -}}
|
||||
{{- "" -}}
|
||||
{{- else -}}
|
||||
{{- $port -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.portSuffix" -}}
|
||||
{{- $port := include "applaunchpad-frontend.port" . -}}
|
||||
{{- if $port -}}:{{ $port }}{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.mainConfigPort" -}}
|
||||
{{- $port := trimPrefix ":" (toString .Values.applaunchpadConfig.cloudPort) -}}
|
||||
{{- if $port -}}{{ $port }}{{- else -}}443{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.mainConfigHTTPPort" -}}
|
||||
{{- $port := trimPrefix ":" (toString .Values.applaunchpadConfig.httpPort) -}}
|
||||
{{- if $port -}}{{ $port }}{{- else -}}80{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.cloudOrigin" -}}
|
||||
{{- include "applaunchpad-frontend.scheme" . -}}://{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.wildcardCloudOrigin" -}}
|
||||
{{- include "applaunchpad-frontend.scheme" . -}}://*.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.host" -}}
|
||||
{{- default (printf "applaunchpad.%s" .Values.applaunchpadConfig.cloudDomain) .Values.ingress.host -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "applaunchpad-frontend.url" -}}
|
||||
{{- include "applaunchpad-frontend.scheme" . -}}://{{ include "applaunchpad-frontend.host" . }}{{ include "applaunchpad-frontend.portSuffix" . }}
|
||||
{{- end }}
|
||||
@@ -1,19 +0,0 @@
|
||||
apiVersion: app.sealos.io/v1
|
||||
kind: App
|
||||
metadata:
|
||||
name: applaunchpad
|
||||
namespace: app-system
|
||||
spec:
|
||||
data:
|
||||
desc: App Launchpad
|
||||
url: {{ include "applaunchpad-frontend.url" . | quote }}
|
||||
icon: {{ printf "%s/logo.svg" (include "applaunchpad-frontend.url" .) | quote }}
|
||||
i18n:
|
||||
zh:
|
||||
name: 应用管理
|
||||
zh-Hans:
|
||||
name: 应用管理
|
||||
menuData:
|
||||
name: App Launchpad
|
||||
type: iframe
|
||||
displayType: normal
|
||||
@@ -1,70 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "applaunchpad-frontend.fullname" . }}-config
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "applaunchpad-frontend.labels" . | nindent 4 }}
|
||||
data:
|
||||
config.yaml: |-
|
||||
cloud:
|
||||
domain: {{ .Values.applaunchpadConfig.cloudDomain | quote }}
|
||||
port: {{ include "applaunchpad-frontend.mainConfigPort" . }}
|
||||
httpPort: {{ include "applaunchpad-frontend.mainConfigHTTPPort" . }}
|
||||
disableHttps: {{ .Values.applaunchpadConfig.disableHttps }}
|
||||
desktopDomain: {{ .Values.applaunchpadConfig.cloudDomain | quote }}
|
||||
userDomains:
|
||||
- name: {{ .Values.applaunchpadConfig.cloudDomain | quote }}
|
||||
secretName: {{ .Values.applaunchpadConfig.certSecretName | quote }}
|
||||
launchpad:
|
||||
infrastructure:
|
||||
provider: alibaba
|
||||
requiresIcpReg: false
|
||||
icpRegQueryLink: https://beian.miit.gov.cn/#/Integrated/index
|
||||
domainBindingDocumentationLink: null
|
||||
features:
|
||||
guide: false
|
||||
api: false
|
||||
gpu: false
|
||||
ui:
|
||||
currencySymbol: shellCoin
|
||||
meta:
|
||||
title: "Sealos App Launchpad"
|
||||
description: "Deploy and manage your applications on Sealos"
|
||||
scripts: []
|
||||
appResourceFormSliderConfig:
|
||||
default:
|
||||
cpu: [100, 200, 500, 1000, 2000, 3000, 4000, 8000]
|
||||
memory: [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384]
|
||||
default-gpu:
|
||||
cpu: [8000, 16000, 32000, 48000, 64000, 80000, 108000]
|
||||
memory: [16384, 32768, 65536, 131072, 262144, 524288, 614400]
|
||||
components:
|
||||
monitoring:
|
||||
url: {{ .Values.applaunchpadConfig.monitorUrl | quote }}
|
||||
billing:
|
||||
url: {{ .Values.applaunchpadConfig.billingUrl | quote }}
|
||||
logging:
|
||||
enabled: true
|
||||
url: {{ .Values.applaunchpadConfig.logUrl | quote }}
|
||||
eventAnalysis:
|
||||
enabled: false
|
||||
url: https://fastgpt.run/api/openapi/v1
|
||||
fastGPTKey: ""
|
||||
fileManager:
|
||||
uploadLimit: 50 # MB
|
||||
downloadLimit: 100 # MB
|
||||
analytics:
|
||||
gtm:
|
||||
enabled: false
|
||||
gtmId: ""
|
||||
rybbit:
|
||||
host: {{ .Values.applaunchpadConfig.rybbitHost | quote }}
|
||||
siteId: {{ .Values.applaunchpadConfig.rybbitSiteId | quote }}
|
||||
checkIcpReg:
|
||||
enabled: false
|
||||
endpoint: cdn.aliyuncs.com
|
||||
accessKeyID: ''
|
||||
accessKeySecret: ''
|
||||
domainChallengeSecret: default-dev-secret-change-in-production
|
||||
pvcStorageMax: 100
|
||||
@@ -1,57 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "applaunchpad-frontend.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "applaunchpad-frontend.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "applaunchpad-frontend.selectorLabels" . | nindent 6 }}
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 25%
|
||||
maxSurge: 25%
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "applaunchpad-frontend.labels" . | nindent 8 }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
env:
|
||||
- name: NODE_TLS_REJECT_UNAUTHORIZED
|
||||
value: {{ .Values.applaunchpadConfig.tlsRejectUnauthorized | quote }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
image: {{ .Values.image | quote }}
|
||||
imagePullPolicy: {{ .Values.imagePullPolicy }}
|
||||
volumeMounts:
|
||||
- mountPath: /app/data/config.yaml
|
||||
name: applaunchpad-frontend-volume
|
||||
subPath: config.yaml
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: applaunchpad-frontend-volume
|
||||
configMap:
|
||||
name: {{ include "applaunchpad-frontend.fullname" . }}-config
|
||||
@@ -1,58 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: {{ .Values.ingress.className | quote }}
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
more_clear_headers "X-Frame-Options:";
|
||||
more_set_headers "Content-Security-Policy: default-src * blob: data: *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }}; img-src * data: blob: resource: *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }}; connect-src * ws: wss: blob: resource:; style-src 'self' 'unsafe-inline' blob: *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} resource:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} resource: *.baidu.com *.bdstatic.com{{ if .Values.applaunchpadConfig.rybbitHost }} {{ .Values.applaunchpadConfig.rybbitHost }}{{ end }}; frame-src 'self' *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} mailto: tel: weixin: mtt: *.baidu.com; frame-ancestors 'self' {{ include "applaunchpad-frontend.cloudOrigin" . }} {{ include "applaunchpad-frontend.wildcardCloudOrigin" . }}";
|
||||
more_set_headers "X-Xss-Protection: 1; mode=block";
|
||||
higress.io/response-header-control-remove: X-Frame-Options
|
||||
higress.io/response-header-control-update: |
|
||||
Content-Security-Policy "default-src * blob: data: *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }}; img-src * data: blob: resource: *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }}; connect-src * ws: wss: blob: resource:; style-src 'self' 'unsafe-inline' blob: *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} resource:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} resource: *.baidu.com *.bdstatic.com{{ if .Values.applaunchpadConfig.rybbitHost }} {{ .Values.applaunchpadConfig.rybbitHost }}{{ end }}; frame-src 'self' *.{{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} {{ .Values.applaunchpadConfig.cloudDomain }}{{ include "applaunchpad-frontend.portSuffix" . }} mailto: tel: weixin: mtt: *.baidu.com; frame-ancestors 'self' {{ include "applaunchpad-frontend.cloudOrigin" . }} {{ include "applaunchpad-frontend.wildcardCloudOrigin" . }}"
|
||||
X-Xss-Protection "1; mode=block"
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
name: {{ include "applaunchpad-frontend.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className | quote }}
|
||||
rules:
|
||||
- host: {{ include "applaunchpad-frontend.host" . | quote }}
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: /
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "applaunchpad-frontend.fullname" . }}
|
||||
port:
|
||||
number: {{ .Values.service.port }}
|
||||
{{- if not (eq (toString .Values.applaunchpadConfig.disableHttps) "true") }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ include "applaunchpad-frontend.host" . | quote }}
|
||||
secretName: {{ .Values.applaunchpadConfig.certSecretName | quote }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: {{ .Values.ingress.className | quote }}
|
||||
name: applaunchpad-challenge
|
||||
namespace: {{ .Release.Namespace }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className | quote }}
|
||||
rules:
|
||||
- host: ""
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: {{ include "applaunchpad-frontend.fullname" . }}
|
||||
port:
|
||||
number: {{ .Values.service.port }}
|
||||
path: /api/.well-known/applaunchpad-domain-challenge
|
||||
pathType: Prefix
|
||||
@@ -1,16 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "applaunchpad-frontend.labels" . | nindent 4 }}
|
||||
name: {{ include "applaunchpad-frontend.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
targetPort: {{ .Values.service.port }}
|
||||
selector:
|
||||
{{- include "applaunchpad-frontend.selectorLabels" . | nindent 4 }}
|
||||
@@ -1,67 +0,0 @@
|
||||
# Default values for applaunchpad frontend helm chart.
|
||||
|
||||
image: ghcr.io/labring/sealos-applaunchpad-frontend:latest
|
||||
imagePullPolicy: Always
|
||||
|
||||
imagePullSecrets: []
|
||||
fullnameOverride: "applaunchpad-frontend"
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext: {}
|
||||
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- "ALL"
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2048Mi
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 128Mi
|
||||
|
||||
ingress:
|
||||
className: nginx
|
||||
host: ""
|
||||
annotations: {}
|
||||
|
||||
# cloudDomain, cloudPort, httpPort, disableHttps and certSecretName are auto-configured by entrypoint.sh from sealos-config.
|
||||
applaunchpadConfig:
|
||||
cloudDomain: "127.0.0.1.nip.io"
|
||||
cloudPort: ""
|
||||
httpPort: ""
|
||||
disableHttps: false
|
||||
certSecretName: "wildcard-cert"
|
||||
monitorUrl: "http://launchpad-monitor.sealos.svc.cluster.local:8428"
|
||||
billingUrl: "http://account-service.account-system.svc:2333"
|
||||
logUrl: ""
|
||||
tlsRejectUnauthorized: "1"
|
||||
# Rybbit Analytics (self-hosted); empty disables tracking
|
||||
rybbitHost: ""
|
||||
rybbitSiteId: ""
|
||||
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: "app"
|
||||
operator: In
|
||||
values:
|
||||
- applaunchpad-frontend
|
||||
topologyKey: "kubernetes.io/hostname"
|
||||
@@ -1 +0,0 @@
|
||||
docker.io/library/nginx:latest
|
||||
@@ -1,7 +0,0 @@
|
||||
import { AppConfig } from './src/types/config';
|
||||
|
||||
declare global {
|
||||
var __APP_CONFIG__: Readonly<AppConfig> | undefined;
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"entry": "./public/locales/zh",
|
||||
"entryLocale": "zh",
|
||||
"output": "./public/locales",
|
||||
"outputLocales": [
|
||||
"en"
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//next-i18next.config.js
|
||||
/**
|
||||
* @type {import('next-i18next').UserConfig}
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
i18n: {
|
||||
defaultLocale: 'zh',
|
||||
locales: ['en', 'zh'],
|
||||
localeDetection: false
|
||||
},
|
||||
reloadOnPrerender: process.env.NODE_ENV === 'development'
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const { i18n } = require('./next-i18next.config');
|
||||
const path = require('path');
|
||||
const nextConfig = {
|
||||
i18n,
|
||||
output: 'standalone',
|
||||
reactStrictMode: false,
|
||||
compress: true,
|
||||
webpack: (config, { isServer }) => {
|
||||
config.module.rules = config.module.rules.concat([
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: /\.[jt]sx?$/,
|
||||
use: ['@svgr/webpack']
|
||||
}
|
||||
]);
|
||||
config.plugins = [...config.plugins];
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = {
|
||||
fs: false
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
transpilePackages: ['@sealos/driver', '@sealos/ui', '@sealos/shadcn-ui', 'geist'],
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
instrumentationHook: true
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/v2alpha/openapi.json',
|
||||
destination: '/api/v2alpha/openapi'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -1,109 +0,0 @@
|
||||
{
|
||||
"name": "applaunchpad",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm run config:gen-json-schema && next dev",
|
||||
"config:gen-json-schema": "pnpm --silent dlx tsx -e \"import { z } from 'zod'; import { AppConfigSchema as s } from './src/types/config'; console.log(JSON.stringify(z.toJSONSchema(s)));\" > ./data/config.schema.json",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest",
|
||||
"test:unit": "vitest run __tests__/unit",
|
||||
"test:components": "vitest --project components",
|
||||
"test:components:ci": "vitest run --project components",
|
||||
"test:watch": "vitest watch",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"link-sdk": "rm -rf node_modules/sealos-desktop-sdk && yalc link sealos-desktop-sdk",
|
||||
"unlink-sdk": "yalc remove --all && pnpm install sealos-desktop-sdk"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/anatomy": "^2.2.1",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/next-js": "^2.1.5",
|
||||
"@chakra-ui/react": "^2.8.1",
|
||||
"@chakra-ui/system": "^2.6.1",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@kubernetes/client-node": "^0.18.1",
|
||||
"@scalar/api-reference-react": "^0.8.36",
|
||||
"@sealos/driver": "workspace:^",
|
||||
"@sealos/gtm": "workspace:^",
|
||||
"@sealos/shadcn-ui": "workspace:^",
|
||||
"@sealos/shared": "workspace:^",
|
||||
"@sealos/ui": "workspace:^",
|
||||
"@tanstack/react-query": "^4.35.3",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"@types/jsonwebtoken": "^9.0.3",
|
||||
"ansi_up": "^5.2.1",
|
||||
"axios": "^1.5.1",
|
||||
"base64-stream": "^1.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dns": "^0.2.2",
|
||||
"dns-packet": "^5.6.1",
|
||||
"echarts": "^5.4.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"formidable": "^3.5.1",
|
||||
"framer-motion": "^10.16.4",
|
||||
"i18next": "^23.11.5",
|
||||
"immer": "^9.0.21",
|
||||
"js-base64": "^3.7.7",
|
||||
"js-cookie": "^3.0.5",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.476.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "14.2.35",
|
||||
"next-i18next": "^15.3.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react": "18.3.1",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.46.2",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"sass": "^1.68.0",
|
||||
"sealos-desktop-sdk": "workspace:*",
|
||||
"sonner": "^2.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"zod": "^4.3.5",
|
||||
"zod-openapi": "^4.2.4",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "^6.5.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"geist": "^1.4.2",
|
||||
"@vitest/browser": "4.0.15",
|
||||
"@vitest/browser-playwright": "4.0.15",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/base64-stream": "^1.0.5",
|
||||
"@types/dns-packet": "^5.6.5",
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/js-cookie": "^3.0.4",
|
||||
"@types/js-yaml": "^4.0.6",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/nprogress": "^0.2.1",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.7",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/ui": "4.0.12",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-config-next": "13.5.9",
|
||||
"jsdom": "^27.2.0",
|
||||
"svg-inline-loader": "^0.8.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.12",
|
||||
"webpack-bundle-analyzer": "^4.9.1"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 589 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.25" y="0.25" width="71.5" height="71.5" rx="35.75" stroke="#6EE7B7" stroke-width="0.5"/>
|
||||
<rect x="16.25" y="16.25" width="39.5" height="39.5" rx="19.75" fill="#ECFDF5"/>
|
||||
<rect x="16.25" y="16.25" width="39.5" height="39.5" rx="19.75" stroke="#6EE7B7" stroke-width="0.5"/>
|
||||
<path d="M33 36L35 38L39 34M46 36C46 41.5228 41.5228 46 36 46C30.4772 46 26 41.5228 26 36C26 30.4772 30.4772 26 36 26C41.5228 26 46 30.4772 46 36Z" stroke="#10B981" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 619 B |
@@ -1,517 +0,0 @@
|
||||
{
|
||||
"error_codes": "Error Codes",
|
||||
"error_codes_tooltip_label": "Error code description",
|
||||
"error_codes_tooltip_content": "Error code data comes from the gateway and may be affected by services such as nginx, so it is for reference only.",
|
||||
"last_5_mins": "last 5 mins",
|
||||
"counts": "counts",
|
||||
"no_errors_found": "No Errors Found",
|
||||
"Abnormality Detected": "Issue Detected",
|
||||
"App Name is required": "App Name is required",
|
||||
"Invalid name": "Invalid name",
|
||||
"Use only lowercase letters, numbers, or hyphens (-)": "Use only lowercase letters, numbers, or hyphens (-).",
|
||||
"Must start/end with a letter or number": "Must start with a lowercase letter and end with a lowercase letter or number.",
|
||||
"Must start with a lowercase letter": "Must start with a lowercase letter.",
|
||||
"Must end with a lowercase letter or number": "Must end with a lowercase letter or number.",
|
||||
"App name base length limit": "Use {{length}} characters or fewer. We reserve space for the NodePort Service suffix and Pod suffix.",
|
||||
"Usage": "Usage",
|
||||
"Target": "Target",
|
||||
"Accessible": "Accessible",
|
||||
"Active": "Active",
|
||||
"Add": "Add",
|
||||
"add_conditions": "Add Conditions",
|
||||
"Add Port": "Add Port",
|
||||
"Add volume": "Add Volumes",
|
||||
"Advanced Configuration": "Settings",
|
||||
"Age": "Age",
|
||||
"Amount": "Amount",
|
||||
"AnticipatedPrice": "The Estimated Cost",
|
||||
"App Name": "Name",
|
||||
"App Store": "App Store",
|
||||
"Application Deployment": "Application Deployment",
|
||||
"Application Source": "Application Source",
|
||||
"Application Type": "Type",
|
||||
"Application failed": "Failed to pause application",
|
||||
"Application paused": "Application Paused",
|
||||
"Applications": "Applications",
|
||||
"Are you sure you want to delete the file or folder?": "Are you sure you want to delete this file/folder?",
|
||||
"Attribute": "Attribute",
|
||||
"Auto scaling": "Scaling",
|
||||
"Balance": "Balance",
|
||||
"Basic Config": "Basic",
|
||||
"Basic Information": "Basic Infomation",
|
||||
"CNAME Tips": "To set up a custom domain, please add a `CNAME` record for your domain pointing to {{domain}} at your domain registrar. You can bind your custom domain once the DNS resolution takes effect.",
|
||||
"CPU": "CPU",
|
||||
"CPU target is the CPU utilization rate of any container": "Target CPU is the utilization of any single container",
|
||||
"CPU target value": "Target CPU Utilization",
|
||||
"Can help you deploy any Docker image": "Rich image warehouse, supporting any Docker image",
|
||||
"Can not change storage path": "Storage mount path cannot be modified",
|
||||
"Cancel": "Cancel",
|
||||
"Card": "cards",
|
||||
"Click Manage to add configmap": "Click Manage to add a configmap",
|
||||
"Click Manage to add environment variable": "Click Manage to add an environment variable",
|
||||
"Click Manage to add storage": "Click Manage to add storage",
|
||||
"Click on any shadow to skip": "Click on any shadow to skip",
|
||||
"Click the Deploy Application button": "Click \\\"Deploy Application\\\"",
|
||||
"Cname auth error: customDomain's cname is not equal to publicDomain": "CNAME error. You must configure CNAME to the provided domain before binding.",
|
||||
"Command": "Command",
|
||||
"Collapse": "Collapse",
|
||||
"Command parameters": "Arguments",
|
||||
"Config Form": "Form",
|
||||
"ConfigMap Conflict": "ConfigMap Conflict",
|
||||
"ConfigMap Path Conflict": "ConfigMap Path Conflict",
|
||||
"ConfigMap Tip": "ConfigMap",
|
||||
"Configurable number of instances or automatic horizontal scaling": "Configurable replica count and auto horizontal scaling",
|
||||
"Configuration File": "Configmaps",
|
||||
"Confirm": "Confirm",
|
||||
"Confirm Deployment": "Confirm Deployment",
|
||||
"Confirm Update": "Confirm Update",
|
||||
"Are you sure you want to deploy the application?": "Are you sure you want to deploy the application?",
|
||||
"Are you sure you want to update the application?": "Are you sure you want to update the application?",
|
||||
"Confirm Deploy Application?": "Are you sure you want to deploy the application?",
|
||||
"Confirm Update Application?": "Are you sure you want to update the application?",
|
||||
"Confirm deletion": "Yes",
|
||||
"Confirm to restart this application?": "Are you sure you want to restart this application?",
|
||||
"Container Port": "Container Port",
|
||||
"Containers": "Containers",
|
||||
"Continue": "Continue",
|
||||
"Copy": "Copy",
|
||||
"Copy Domain Success": "Domain copied",
|
||||
"Copy Failed": "Failed to copy",
|
||||
"Copy Success": "Copied",
|
||||
"Create Application": "Create App",
|
||||
"Create Folder": "New Folder",
|
||||
"Create Folder Success": "Folder Created Successfully",
|
||||
"Create Folder Failed": "Failed to Create Folder",
|
||||
"Please enter the folder name": "Please enter the folder name",
|
||||
"Prompt": "Prompt",
|
||||
"Creating": "Creating",
|
||||
"Creation Time": "Created At",
|
||||
"Custom Domain": "Custom Domain",
|
||||
"Custom Domain Error": "Failed to add custom domain. Please verify CNAME configuration.",
|
||||
"Data cannot be communicated between multiple instances": "Data cannot be shared between instances",
|
||||
"Day": "Day",
|
||||
"Delete": "Delete",
|
||||
"Delete Failed": "Delete Failed",
|
||||
"Delete Success": "Delete Success",
|
||||
"Delete anyway": "Delete anyway",
|
||||
"Deletion warning": "Deletion warning",
|
||||
"Deploy": "Deploy",
|
||||
"Deploy Application": "Deploy",
|
||||
"Deployment Failed": "Creation failed",
|
||||
"Deployment Mode": "Deployment Infomation",
|
||||
"Deployment Successful": "Deployment succeeded",
|
||||
"Details": "Details",
|
||||
"docs": "Docs",
|
||||
"Edit Env Variable": "Edit Environment Variables",
|
||||
"Edit Environment Variables": "Edit Environment Variables",
|
||||
"Enabled": "Enabled",
|
||||
"Disabled": "Disabled",
|
||||
"Env Placeholder": "one per line, key and value separated by colon or equals sign, e.g.:\nmongoUrl=127.0.0.1:8000\nredisUrl:127.0.0.0:8001\n-env1 =test",
|
||||
"Invalid Env Name": "Invalid env name: {{name}}. Use letters, digits, '_', '-', or '.', and do not start with a digit.",
|
||||
"Environment Variables": "Environment Variables",
|
||||
"Export": "Export",
|
||||
"Events": "Events",
|
||||
"Export Domain": "Assigned Domain",
|
||||
"File Management": "File Manager",
|
||||
"File Name": "File Name",
|
||||
"File Value can not empty": "File content is required",
|
||||
"File Value Placeholder": "File content",
|
||||
"File is too large tip": "{{name}} is too large to upload.",
|
||||
"Filename can not empty": "File Name is required",
|
||||
"Filter": "Filter",
|
||||
"Fixed instance": "Fixed",
|
||||
"Folder Name": "Folder Name",
|
||||
"GPU": "GPU",
|
||||
"Go to recharge": "Recharge Now",
|
||||
"Gpu under inventory Tip": "{{gputype}} is out of stock. Please select a different type or reduce the quantity",
|
||||
"Group": "Group",
|
||||
"If no, the default command is used": "Leave blank to use the default command",
|
||||
"Image": "Image",
|
||||
"Image Address": "Image Registry",
|
||||
"Image Name": "Image Name",
|
||||
"Image Name (Private)": "Image Name (Private)",
|
||||
"Image name cannot be empty": "Image name is required.",
|
||||
"Image name cannot exceed 255 characters": "Image name must be 255 characters or less.",
|
||||
"Input your custom domain": "Enter your custom domain",
|
||||
"Intelligent Analysis": "Smart Insights",
|
||||
"Inventory": "Stock",
|
||||
"Items": "pods",
|
||||
"Limit CPU": "CPU Limit",
|
||||
"Limit Memory": "Memory Limit",
|
||||
"Local Storage": "Local Storage",
|
||||
"Log": "Logs",
|
||||
"More Logs": "More Logs",
|
||||
"Manage": "Manage",
|
||||
"Manage all resources": "Manage all resources",
|
||||
"Max Storage Value": "Maximum size is",
|
||||
"Memory": "Memory",
|
||||
"Memory target value": "Target Memory Usage",
|
||||
"Min Storage Value": "Minimum size is",
|
||||
"Mount Path Auth": "Mount path must match: /^[0-9a-zA-Z_/][0-9a-zA-Z_/.-]*[0-9a-zA-Z_/]$/",
|
||||
"Name": "Name",
|
||||
"Network Configuration": "Network Settings",
|
||||
"Network Configuration Description": "TCP and UDP ports will be allocated after deployment.",
|
||||
"Network port conflict": "Container port conflict",
|
||||
"No Data Available": "No Data",
|
||||
"No GPU": "No GPU",
|
||||
"No events yet": "No events yet",
|
||||
"None": "None",
|
||||
"Not Configured": "Not Set",
|
||||
"Not Enabled": "Disabled",
|
||||
"Not allowed to change app name": "Application name cannot be changed",
|
||||
"Note that you cannot retrieve it after deletion": "This action is irreversible",
|
||||
"Number of Instances": "Instances",
|
||||
"Number of instances: 1 to 20": "Instance range: 1 to 20",
|
||||
"Open Link": "Open Link",
|
||||
"Open Public Access": "Internet Access",
|
||||
"Operation": "Operation",
|
||||
"Option": "Optional",
|
||||
"Owner": "Owner",
|
||||
"Page": "Pages",
|
||||
"Parameters": "Arguments",
|
||||
"Password": "Password",
|
||||
"Password for the image registry": "Image Registry Password",
|
||||
"Pause": "Pause",
|
||||
"Paused": "Paused",
|
||||
"Perday": "/day",
|
||||
"Please confirm to restart the Pod?": "Are you sure you want to restart this pod?",
|
||||
"Please enter": "Please enter",
|
||||
"Pod": "Pod",
|
||||
"Pod Name": "Pod Name",
|
||||
"Pods": "Pods",
|
||||
"Pods List": "Pod List",
|
||||
"Port": "Port",
|
||||
"Private": "Private",
|
||||
"Private Address": "Private Address",
|
||||
"Problem Analysis": "Troubleshooting",
|
||||
"Protocol": "Protocol",
|
||||
"Public": "Public",
|
||||
"Public Access": "Internet Access",
|
||||
"Public Address": "Public Address",
|
||||
"Ready": "Preparing",
|
||||
"Real-time Monitoring": "Live Monitoring",
|
||||
"Reboot Success": "Restarted",
|
||||
"Remind": "Remind",
|
||||
"Replicas": "Replicas",
|
||||
"Replicas Range": "Replicas Range",
|
||||
"Restart": "Restart",
|
||||
"Restart Failed": "Failed to restart",
|
||||
"Restart Success": "Restarted",
|
||||
"Restarts": "Restarts",
|
||||
"Restarts Num": "Restarts",
|
||||
"Run command": "Command",
|
||||
"Running": "Running",
|
||||
"Separated by spaces": "Space separated, e.g.:",
|
||||
"Size": "Size",
|
||||
"Start Failed": "Failed to start",
|
||||
"Start Successful": "Application started",
|
||||
"Start Up": "Start",
|
||||
"Starts with a letter and can contain only lowercase letters, digits, and hyphens (-)": "Must start with a lowercase letter, followed by lowercase letters, digits, or hyphens (-)",
|
||||
"Enter an app name.": "Enter an app name.",
|
||||
"Stateful": "Stateful",
|
||||
"Stateless": "Stateless",
|
||||
"Status": "Status",
|
||||
"Storage": "Mounted Volumes",
|
||||
"Storage Range": "Storage Range",
|
||||
"Storage Value can not empty": "Storage size is required",
|
||||
"Storage limit reached": "Storage limit reached",
|
||||
"Storage path can not empty": "Storage mount path is required",
|
||||
"Store At Least One": "Keep at Least One Storage",
|
||||
"Submit Error": "Submission Error",
|
||||
"Such as": "For example: ",
|
||||
"Terminal": "Terminal",
|
||||
"The Cpu target is empty": "Target CPU is required",
|
||||
"The application name can contain only lowercase letters, digits, and hyphens (-) and must start with a letter": "Application name can only contain lowercase letters, digits, and hyphens (-), and must start with a letter",
|
||||
"The cpu target value must be positive": "Target CPU must be a positive number",
|
||||
"The image cannot be empty": "Private image URL is required",
|
||||
"The maximum number of instances is 20": "Maximum instance count is 20",
|
||||
"The minimum number of instances is 1": "Minimum instance count is 1",
|
||||
"The number of instances cannot be empty": "Instance count is required",
|
||||
"The password cannot be empty": "Password is required",
|
||||
"The target cpu value must be less than 100": "Target CPU must be less than 100",
|
||||
"The user name cannot be empty": "Username is required",
|
||||
"To Confirm": "Confirm",
|
||||
"Total": "Total",
|
||||
"TotalPrice": "Total",
|
||||
"Under Stock": "Out of Stock",
|
||||
"Update": "Update",
|
||||
"Update Application": "Update Application",
|
||||
"Update Failed": "Update Failed",
|
||||
"Update Successful": "Update succeeded",
|
||||
"Update Time": "Updated At",
|
||||
"UpdateTime": "Updated At",
|
||||
"Username": "Username",
|
||||
"Username for the image registry": "Image Registry Username",
|
||||
"Waiting": "Waiting",
|
||||
"Warning": "Warning",
|
||||
"analysis_error": "Smart analysis failed.",
|
||||
"Annotations": "Annotations",
|
||||
"Controlled By": "Controlled By",
|
||||
"Count": "Count",
|
||||
"First Seen": "First Seen",
|
||||
"Last Occur": "Last Occur",
|
||||
"Labels": "Labels",
|
||||
"Message": "Message",
|
||||
"Reason": "Reason",
|
||||
"YAML File": "YAML",
|
||||
"Yes": "Yes",
|
||||
"You can enter the container through the terminal": "Access container via terminal",
|
||||
"You have successfully deployed an application": "Application deployed successfully",
|
||||
"You haven't created any application yet": "You don't have any applications yet",
|
||||
"add_credit": "Add Credit",
|
||||
"advanced_filter": "Advance",
|
||||
"all": "All",
|
||||
"stopped": "Stopped",
|
||||
"terminated": "Terminated",
|
||||
"app": {
|
||||
"Resource Quota": "Resource Quota",
|
||||
"The applied CPU exceeds the quota": "Requested CPU exceeds your quota. Please contact admin.",
|
||||
"The applied GPU exceeds the quota": "Requested GPU exceeds your quota. Please contact admin.",
|
||||
"The applied memory exceeds the quota": "Requested storage exceeds your quota. Please contact admin.",
|
||||
"The applied nodeports exceeds the quota": "Requested nodeports exceeds your quota. Please contact admin.",
|
||||
"The applied storage exceeds the quota": "The applied storage exceeds the quota",
|
||||
"The container exposed port cannot be empty": "Container port is required",
|
||||
"The maximum number of exposed ports is 65535": "Maximum port number is 65535",
|
||||
"The minimum exposed port is 1": "Minimum port number is 1"
|
||||
},
|
||||
"app_already_exists": "App name already exists. Please use a different name or check your app list.",
|
||||
"app_store": "App Store",
|
||||
"application_source": "Source",
|
||||
"balance": "balance",
|
||||
"capacity": "Capacity",
|
||||
"click_to_copy_tooltip": "Click to copy",
|
||||
"close": "Off",
|
||||
"common": {
|
||||
"Surplus": "Available",
|
||||
"Used": "Used"
|
||||
},
|
||||
"confirm_to_go": "Confirm",
|
||||
"contains": "contains",
|
||||
"cpu": "CPU",
|
||||
"pod": "Pod",
|
||||
"cpu_exceeds_quota": "CPU Insufficient Resources: Your setting of {{requested}} cores exceeds the available {{limit}} cores ({{used}} cores used).",
|
||||
"custom_domain_input_title": "Your Domain",
|
||||
"day": "days",
|
||||
"delete_app_tip": "Are you sure you want to delete this application?",
|
||||
"delete_warning_persistent": "All project data will be permanently lost if you proceed.",
|
||||
"delete_sealaf_app_tip": "The application is deployed via cloud development. \nSimply deleting the app won't clear out all of the app's components, which may still accrue charges. \nTo completely uninstall the app and clean all related components, uninstall the entire app in cloud development.",
|
||||
"delete_template_app_tip": "This app is managed by the app store. You are now going to the app store to delete all app components.",
|
||||
"domain_challenge_verify_failed_toast": "Domain challenge verification failed",
|
||||
"domain_cname_verify_failed_toast": "CNAME record verification failed",
|
||||
"domain_input_tip": "Please purchase a domain name and fill it below.",
|
||||
"domain_invalid_toast": "'{{domain}}' is not a valid domain",
|
||||
"domain_cannot_use_internal": "Cannot use internal domain",
|
||||
"domain_not_bound_modal_cancel": "Cancel",
|
||||
"domain_not_bound_modal_confirm": "Confirm",
|
||||
"domain_not_bound_modal_description": "Are you sure you want to close the pop-up?",
|
||||
"domain_not_bound_modal_title": "Domain Not Bound",
|
||||
"domain_not_bound_modal_warning": "DNS records are not active yet, so your domain isn't bound. Closing the pop-up requires re-entering the domain.",
|
||||
"domain_registration_provider_link_text": "Filling Entry",
|
||||
"domain_registration_query_link_text": "Filling Query",
|
||||
"domain_requires_registration_tip_1": "Please purchase a domain and complete ICP filing through ",
|
||||
"domain_requires_registration_tip_2": ", then fill it in the input field below.",
|
||||
"domain_verification_dns_records": "DNS Records",
|
||||
"domain_verification_dns_records_docs_link_text": "Refer to documentation",
|
||||
"domain_verification_dns_records_tip_1": "Please add a CNAME record for your domain pointing to ",
|
||||
"domain_verification_dns_records_tip_2": " with your DNS provider. Once the DNS update takes effect, you can bind your custom domain.",
|
||||
"domain_verification_dns_records_ttl": "TTL",
|
||||
"domain_verification_dns_records_ttl_auto": "Auto",
|
||||
"domain_verification_dns_records_type": "Type",
|
||||
"domain_verification_dns_records_type_cname": "CNAME",
|
||||
"domain_verification_dns_records_value": "Value",
|
||||
"domain_verification_input_clear": "Unbind",
|
||||
"domain_verification_input_edit": "Edit",
|
||||
"domain_verification_input_save": "Save",
|
||||
"domain_verification_needed": "Verification Needed",
|
||||
"domain_verification_refresh": "Refresh",
|
||||
"domain_verification_success": "Your domain has been successfully bound.",
|
||||
"domain_verified": "Verified",
|
||||
"download": "Download",
|
||||
"driver": {
|
||||
"access_application": "Access Application",
|
||||
"click_anywhere": "Click anywhere to finish the tutorial.",
|
||||
"configure_launchpad": "Configure Launchpad",
|
||||
"copy_address": "Copy the private or public address for access",
|
||||
"create_launchpad": "Create Launchpad",
|
||||
"create_launchpad_header": "Feel free to explore. No changes will be saved and no charges will apply in this guided demo.",
|
||||
"define_image_settings": "Define image settings, and adjust CPU & memory as needed",
|
||||
"define_settings": "Define your application settings and start deployment",
|
||||
"find_guide_tip": "You can always find your way back to this guide in the top navigation bar. Happy exploring!",
|
||||
"got_it": "Got it",
|
||||
"next": "Next",
|
||||
"quit_guide": "Explore More Guides",
|
||||
"still_here": "We're still here!"
|
||||
},
|
||||
"end": "End",
|
||||
"equal": "equal",
|
||||
"export_log": "Export",
|
||||
"field_name": "Select Field",
|
||||
"field_settings": "Field Setting",
|
||||
"file": "File",
|
||||
"file value": "File content",
|
||||
"filename": "File Name",
|
||||
"filter": "Filter",
|
||||
"first_charge": "First Recharge Discount",
|
||||
"first_charge_tip": "For some specifications, you can enjoy double the gift amount when you recharge for the first time.",
|
||||
"forbidden_create_app": "You do not have sufficient permissions to create an application in the current workspace. Please contact the Owner of the workspace to request the necessary permissions.",
|
||||
"form": {
|
||||
"add_configmap": "Add Configmaps",
|
||||
"storage_path_placeholder": "For Example: /data"
|
||||
},
|
||||
"gift": "gift",
|
||||
"gift amount tip": "Top up {{amount}} to get {{gift}} bonus",
|
||||
"gift time tip": "Within {{time}} hours",
|
||||
"gpu": "GPU",
|
||||
"gpu_exceeds_quota": "GPU Insufficient Resources: Your setting of {{requested}} GPUs exceeds the available {{limit}} GPUs ({{used}} GPUs used).",
|
||||
"greater_than": "Greater than",
|
||||
"grpcs": "gRPCS",
|
||||
"guide_deploy_button": "Complete creation",
|
||||
"guide_deploy_storage": "Massive storage to meet various application needs",
|
||||
"guide_detail_monitor": "Visual resource monitoring",
|
||||
"guide_detail_network": "Provide intranet and extranet access addresses and automatically configure SSL certificates",
|
||||
"guide_detail_operate": "Log|Terminal|Details|Restart",
|
||||
"guide_detail_update_button": "Adjust in real time as needed",
|
||||
"hidden": "Hidden",
|
||||
"hour": "hours",
|
||||
"hour-singular": "hour",
|
||||
"https": "HTTPS",
|
||||
"icp_reg_not_registered": "Domain not filed",
|
||||
"infrastructure": {
|
||||
"providers": {
|
||||
"alibaba": {
|
||||
"domain_reg_link": "https://beian.aliyun.com/pcContainer/selfEntity",
|
||||
"name": "Alibaba Cloud"
|
||||
},
|
||||
"tencent": {
|
||||
"domain_reg_link": "https://cloud.tencent.com/product/ba",
|
||||
"name": "Tencent Cloud"
|
||||
},
|
||||
"volcano": {
|
||||
"domain_reg_link": "https://console.volcengine.com/beian/overview/",
|
||||
"name": "Volcano Engine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"json_mode": "JSON Mode",
|
||||
"jump_message": "This app can't be used standalone. Click OK to open it via Sealos Desktop.",
|
||||
"jump_prompt": "Navigation Notice",
|
||||
"keyword": "Keywords",
|
||||
"less_than": "Less than",
|
||||
"let me think again": "Let me reconsider",
|
||||
"logNumber": "Log Counts",
|
||||
"log_number": "Log Number",
|
||||
"log_table": {
|
||||
"_msg": "Message",
|
||||
"_time": "Time",
|
||||
"container": "Container",
|
||||
"pod": "Pod",
|
||||
"stream": "Stream"
|
||||
},
|
||||
"logs": "Logs",
|
||||
"memory": "Memory",
|
||||
"memory_exceeds_quota": "Memory Insufficient Resources: Your setting of {{requested}} GB exceeds the available {{limit}} GB ({{used}} GB used).",
|
||||
"minute": "minutes",
|
||||
"monitor": "Monitors",
|
||||
"monitor_tab_network": "Network",
|
||||
"monitor_tab_performance": "Performance",
|
||||
"monitor_tab_storage": "Storage",
|
||||
"storage_usage": "Storage Usage",
|
||||
"network_request_count": "Request Count",
|
||||
"network_request_percent": "Request Percentage",
|
||||
"no_network_configured": "No network configured",
|
||||
"instance": "Instance",
|
||||
"select_instance_service_port": "Select instance (service:port)",
|
||||
"select_port": "Select port",
|
||||
"mount path": "Mount Path",
|
||||
"network_not_ready": "The internal gateway requires 2 to 5 minutes to prepare. Please be patient.",
|
||||
"network_not_ready_icp_reg": "If the domain name has been filed but still shows \"Pending\", please further check whether it has been filed with the service provider in the current available zone.",
|
||||
"new filename": "Rename to",
|
||||
"no_data_available": "No data available",
|
||||
"no_logs_for_now": "No logs for now",
|
||||
"nodeport": "Port",
|
||||
"nodeport_exceeds_quota": "NodePort Insufficient Resources: Your setting of {{requested}} ports exceeds the available {{limit}} ports ({{used}} ports used).",
|
||||
"nodeports": "NodePorts",
|
||||
"normal_filter": "Normal",
|
||||
"not_contains": "not contains",
|
||||
"not_equal": "not",
|
||||
"highlight_stderr": "Highlight Stderr",
|
||||
"or": "or",
|
||||
"original filename": "Original Filename",
|
||||
"overview": "Overview",
|
||||
"pause_message": "Pausing the service will stop the calculation of charges for CPU and memory, but charges for storage and external network ports will still apply. Would you like to pause now?",
|
||||
"pending_to_allocated": "To be allocated",
|
||||
"please enter app name": "please enter: {{appName}}",
|
||||
"please_select": "Please Select",
|
||||
"please_upgrade_plan": {
|
||||
"0": "Please ",
|
||||
"1": "upgrade your plan",
|
||||
"2": " or adjust settings."
|
||||
},
|
||||
"private": "private",
|
||||
"proxy_detected_message": "Using the proxy will degrade the performance of Sealos. To disable the proxy, update your DNS provider.",
|
||||
"proxy_detected_title": "Proxy Detected",
|
||||
"public": "Public",
|
||||
"receive": "Get",
|
||||
"recently": "Last",
|
||||
"refetching_success": "Refresh Successful",
|
||||
"refresh": "refresh",
|
||||
"remark_updated_successfully": "Remark updated successfully",
|
||||
"remarks": "Remarks",
|
||||
"remind": "remind",
|
||||
"rename": "Rename",
|
||||
"Rename Success": "Rename Success",
|
||||
"Rename Failed": "Rename Failed",
|
||||
"sealaf": "sealaf",
|
||||
"search": "Search",
|
||||
"selected": "Selected",
|
||||
"set_remarks": "set remarks",
|
||||
"set_remarks_placeholder": "Set remarks to help identify this app or service more easily.",
|
||||
"set_remarks_title": "Set Remarks",
|
||||
"shared": "Shared",
|
||||
"show hidden files": "Show hidden files",
|
||||
"skip": "Skip",
|
||||
"start": "Start",
|
||||
"storage": "Storage",
|
||||
"streaming_logs": "Streaming logs",
|
||||
"success": "Success",
|
||||
"target_value": "Target Value",
|
||||
"terminated_logs": "Terminated logs",
|
||||
"time": "Range",
|
||||
"time_zone": "Time Zone",
|
||||
"total_price_tip": "The estimated cost does not include port fees and traffic fees, and is subject to actual usage.",
|
||||
"traffic": "Traffic",
|
||||
"update_remark_failed": "Update remark failed",
|
||||
"update_sealaf_app_tip": "This application is deployed through cloud development. To change the application, please go to cloud development.",
|
||||
"upload": "Upload",
|
||||
"user": {
|
||||
"Insufficient account balance": "Insufficient account balance"
|
||||
},
|
||||
"user_balance_not_enough": "Almost there! You need additional credits for this deployment.",
|
||||
"value": "Enter Value",
|
||||
"visible": "Visable",
|
||||
"vm": "vm",
|
||||
"websocket": "WebSocket",
|
||||
"within_1_day": "Within 1 day",
|
||||
"within_1_hour": "Within 1 hour",
|
||||
"within_5_minute": "Within 5 minute",
|
||||
"No Applications": "No Applications",
|
||||
"create_your_first_application": "Deploy your first app",
|
||||
"click_here_to_create_application": "Click here to deploy an application from docker image in just a few steps",
|
||||
"pod_filtering": "Pod Filtering",
|
||||
"container_filtering": "Container Filtering",
|
||||
"Refresh": "Refresh",
|
||||
"auto_refresh": "Auto Refresh",
|
||||
"no_auto_refresh": "Auto refresh: Off",
|
||||
"set_automatic_refresh": "Set automatic refresh",
|
||||
"Probe": "Probe",
|
||||
"Startup": "Startup",
|
||||
"Liveness": "Liveness",
|
||||
"Readiness": "Readiness",
|
||||
"No startup added yet": "No startup added yet",
|
||||
"No liveness added yet": "No liveness added yet",
|
||||
"No readiness added yet": "No readiness added yet",
|
||||
"replica_filtering": "Replica filtering",
|
||||
"volume_filtering": "Volume filtering",
|
||||
"status_filtering": "Status filtering",
|
||||
"show_all": "show all"
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
{
|
||||
"error_codes": "错误码",
|
||||
"error_codes_tooltip_label": "错误码说明",
|
||||
"error_codes_tooltip_content": "错误码数据来自网关,可能会受到 nginx 等服务影响导致误差,故仅供参考。",
|
||||
"last_5_mins": "最近5分钟",
|
||||
"counts": "次",
|
||||
"no_errors_found": "暂无错误",
|
||||
"Abnormality Detected": "检测到异常",
|
||||
"App Name is required": "应用名称不能为空",
|
||||
"Invalid name": "无效的名称",
|
||||
"Use only lowercase letters, numbers, or hyphens (-)": "只能使用小写字母、数字或连字符 (-)。",
|
||||
"Must start/end with a letter or number": "必须以小写字母开头,并以小写字母或数字结尾。",
|
||||
"Must start with a lowercase letter": "必须以小写字母开头。",
|
||||
"Must end with a lowercase letter or number": "必须以小写字母或数字结尾。",
|
||||
"App name base length limit": "最多输入 {{length}} 个字符。系统会为 NodePort Service 后缀和 Pod 后缀预留长度。",
|
||||
"Usage": "资源配置",
|
||||
"Target": "目标值",
|
||||
"Accessible": "可访问",
|
||||
"Active": "活跃",
|
||||
"Add": "新增",
|
||||
"add_conditions": "添加条件",
|
||||
"Add Port": "添加端口",
|
||||
"Add volume": "新增存储卷",
|
||||
"Advanced Configuration": "高级配置",
|
||||
"Age": "启动时长",
|
||||
"Amount": "数量",
|
||||
"AnticipatedPrice": "预估价格",
|
||||
"App Name": "应用名称",
|
||||
"App Store": "应用商店",
|
||||
"Application Deployment": "应用部署",
|
||||
"Application Source": "应用来源",
|
||||
"Application Type": "应用类型",
|
||||
"Application failed": "暂停应用出现异常",
|
||||
"Application paused": "应用已暂停",
|
||||
"Applications": "应用列表",
|
||||
"Are you sure you want to delete the file or folder?": "确定要删除该文件或文件夹吗?",
|
||||
"Attribute": "属性",
|
||||
"Auto scaling": "弹性伸缩",
|
||||
"Balance": "余额",
|
||||
"Basic Config": "基础配置",
|
||||
"Basic Information": "基础信息",
|
||||
"CNAME Tips": "请到您的域名服务商处,添加该域名的 `CNAME` 解析到 {{domain}},解析生效后即可绑定自定义域名。",
|
||||
"CPU": "CPU",
|
||||
"CPU target is the CPU utilization rate of any container": "CPU 目标值为任一容器的 CPU 利用率",
|
||||
"CPU target value": "CPU 目标值",
|
||||
"Can help you deploy any Docker image": "丰富的镜像仓库,支持任意 Docker 镜像",
|
||||
"Can not change storage path": "不允许修改挂载路径",
|
||||
"Cancel": "取消",
|
||||
"Card": "张",
|
||||
"Click Manage to add configmap": "点击管理添加配置映射",
|
||||
"Click Manage to add environment variable": "点击管理添加环境变量",
|
||||
"Click Manage to add storage": "点击管理添加存储",
|
||||
"Click on any shadow to skip": "点击任意阴影跳过",
|
||||
"Click the Deploy Application button": "点击「部署应用」按钮",
|
||||
"Cname auth error: customDomain's cname is not equal to publicDomain": "CNAME 校验错误,你需要先 CNAME 到指定域名才能绑定",
|
||||
"Command": "启动命令",
|
||||
"Collapse": "收起",
|
||||
"Command parameters": "命令参数",
|
||||
"Config Form": "配置表单",
|
||||
"ConfigMap Conflict": "配置映射冲突",
|
||||
"ConfigMap Path Conflict": "配置文件路径冲突",
|
||||
"ConfigMap Tip": "配置文件",
|
||||
"Configurable number of instances or automatic horizontal scaling": "可配置实例数或自动横向伸缩",
|
||||
"Configuration File": "配置文件",
|
||||
"Confirm": "确认",
|
||||
"Confirm Deployment": "确认部署",
|
||||
"Confirm Update": "确认更新",
|
||||
"Are you sure you want to deploy the application?": "确定要部署该应用吗?",
|
||||
"Are you sure you want to update the application?": "确定要更新该应用吗?",
|
||||
"Confirm Deploy Application?": "确认部署应用?",
|
||||
"Confirm Update Application?": "确认更新应用?",
|
||||
"Confirm deletion": "确认删除",
|
||||
"Confirm to restart this application?": "确认重启该应用?",
|
||||
"Container Port": "容器暴露端口",
|
||||
"Containers": "容器",
|
||||
"Continue": "继续",
|
||||
"Copy": "复制",
|
||||
"Copy Domain Success": "复制域名成功",
|
||||
"Copy Failed": "复制失败",
|
||||
"Copy Success": "复制成功",
|
||||
"Create Application": "新建应用",
|
||||
"Create Folder": "创建文件夹",
|
||||
"Create Folder Success": "创建文件夹成功",
|
||||
"Create Folder Failed": "创建文件夹失败",
|
||||
"Please enter the folder name": "请输入文件夹名称",
|
||||
"Prompt": "提示",
|
||||
"Creating": "创建中",
|
||||
"Creation Time": "创建时间",
|
||||
"Custom Domain": "自定义域名",
|
||||
"Custom Domain Error": "添加自定义域名失败,请检查是否已经配置 CNAME。",
|
||||
"Data cannot be communicated between multiple instances": "不同实例之间数据不互通",
|
||||
"Day": "天",
|
||||
"Delete": "删除",
|
||||
"Delete Failed": "删除失败",
|
||||
"Delete Success": "删除成功",
|
||||
"Delete anyway": "仍要删除",
|
||||
"Deletion warning": "删除警告",
|
||||
"Deploy": "部署",
|
||||
"Deploy Application": "部署应用",
|
||||
"Deployment Failed": "部署失败",
|
||||
"Deployment Mode": "部署模式",
|
||||
"Deployment Successful": "部署成功",
|
||||
"Details": "详情",
|
||||
"docs": "文档",
|
||||
"Edit Env Variable": "编辑环境变量",
|
||||
"Edit Environment Variables": "编辑环境变量",
|
||||
"Enabled": "已启用",
|
||||
"Disabled": "已禁用",
|
||||
"Env Placeholder": "环境变量,每行一个,可用冒号或等号分隔,例如:\nmongoUrl=127.0.0.1:8000\nredisUrl:127.0.0.0:8001\n- env1=test",
|
||||
"Invalid Env Name": "环境变量名不合法:{{name}},只能包含字母、数字、_、-、.,且不能以数字开头",
|
||||
"Environment Variables": "环境变量",
|
||||
"Export": "导出",
|
||||
"Events": "事件",
|
||||
"Export Domain": "出口域名",
|
||||
"File Management": "文件管理",
|
||||
"File Name": "文件名",
|
||||
"File Value can not empty": "文件值不能为空",
|
||||
"File Value Placeholder": "文件内容",
|
||||
"File is too large tip": "文件 {{name}} 过大,无法上传。",
|
||||
"Filename can not empty": "文件名不能为空",
|
||||
"Filter": "筛选",
|
||||
"Fixed instance": "固定实例",
|
||||
"Folder Name": "文件夹名",
|
||||
"GPU": "GPU",
|
||||
"Go to recharge": "去充值",
|
||||
"Gpu under inventory Tip": "{{gputype}} 库存不足,请更换型号或减少数量",
|
||||
"Group": "Group",
|
||||
"If no, the default command is used": "若不填写,将使用默认命令",
|
||||
"Image": "镜像源",
|
||||
"Image Address": "镜像仓库地址",
|
||||
"Image Name": "镜像名",
|
||||
"Image Name (Private)": "镜像名称(私有)",
|
||||
"Image name cannot be empty": "镜像名称不能为空。",
|
||||
"Image name cannot exceed 255 characters": "镜像名称不能超过 255 个字符。",
|
||||
"Input your custom domain": "输入您的自定义域名",
|
||||
"Intelligent Analysis": "智能分析",
|
||||
"Inventory": "库存",
|
||||
"Items": "个实例",
|
||||
"Limit CPU": "CPU Limit",
|
||||
"Limit Memory": "Memory Limit",
|
||||
"Local Storage": "本地存储",
|
||||
"Log": "日志",
|
||||
"More Logs": "更多日志",
|
||||
"Manage": "管理",
|
||||
"Manage all resources": "管理所有资源",
|
||||
"Max Storage Value": "容量最大为",
|
||||
"Memory": "内存",
|
||||
"Memory target value": "内存目标值",
|
||||
"Min Storage Value": "容量最小为",
|
||||
"Mount Path Auth": "挂载路径格式须满足: /^[0-9a-zA-Z_/][0-9a-zA-Z_/.-]*[0-9a-zA-Z_/]$/",
|
||||
"Name": "名字",
|
||||
"Network Configuration": "网络配置",
|
||||
"Network Configuration Description": "部署后会自动分配 TCP/UDP 端口。",
|
||||
"Network port conflict": "容器端口冲突",
|
||||
"No Data Available": "暂无数据",
|
||||
"No GPU": "不使用 GPU",
|
||||
"No events yet": "暂无事件",
|
||||
"None": "暂无",
|
||||
"Not Configured": "未配置",
|
||||
"Not Enabled": "未开启",
|
||||
"Not allowed to change app name": "不允许修改应用名称",
|
||||
"Note that you cannot retrieve it after deletion": "请注意,删除后将无法恢复",
|
||||
"Number of Instances": "实例数",
|
||||
"Number of instances: 1 to 20": "实例数范围:1~20",
|
||||
"Open Link": "打开链接",
|
||||
"Open Public Access": "公网访问",
|
||||
"Operation": "操作",
|
||||
"Option": "选填",
|
||||
"Owner": "所有者",
|
||||
"Page": "页",
|
||||
"Parameters": "运行参数",
|
||||
"Password": "密码",
|
||||
"Password for the image registry": "镜像仓库密码",
|
||||
"Pause": "暂停",
|
||||
"Paused": "已暂停",
|
||||
"Perday": "/每日",
|
||||
"Please confirm to restart the Pod?": "请确认是否重启 Pod ?",
|
||||
"Please enter": "请输入",
|
||||
"Pod": "实例",
|
||||
"Pod Name": "Pod 名称",
|
||||
"Pods": "Pods",
|
||||
"Pods List": "Pod 列表",
|
||||
"Port": "端口",
|
||||
"Private": "私有",
|
||||
"Private Address": "内网地址",
|
||||
"Problem Analysis": "问题分析",
|
||||
"Protocol": "协议",
|
||||
"Public": "公开",
|
||||
"Public Access": "外网访问",
|
||||
"Public Address": "公网地址",
|
||||
"Ready": "准备中",
|
||||
"Real-time Monitoring": "实时监控",
|
||||
"Reboot Success": "重启成功",
|
||||
"Remind": "提醒",
|
||||
"Replicas": "实例数",
|
||||
"Replicas Range": "实例数范围",
|
||||
"Restart": "重启",
|
||||
"Restart Failed": "重启出现异常",
|
||||
"Restart Success": "重启成功",
|
||||
"Restarts": "重启次数",
|
||||
"Restarts Num": "重启次数",
|
||||
"Run command": "运行命令",
|
||||
"Running": "运行中",
|
||||
"Separated by spaces": "以空格分开,如: ",
|
||||
"Size": "文件大小",
|
||||
"Start Failed": "启动出现异常",
|
||||
"Start Successful": "应用已启动",
|
||||
"Start Up": "启动",
|
||||
"Starts with a letter and can contain only lowercase letters, digits, and hyphens (-)": "以小写字母开头,只能包含小写字母、数字和连字符(-)",
|
||||
"Enter an app name.": "请输入应用名称。",
|
||||
"Stateful": "有状态",
|
||||
"Stateless": "无状态",
|
||||
"Status": "状态",
|
||||
"Storage": "存储卷",
|
||||
"Storage Range": "容量范围",
|
||||
"Storage Value can not empty": "容量不能为空",
|
||||
"Storage limit reached": "存储资源不足",
|
||||
"Storage path can not empty": "挂载路径不能为空",
|
||||
"Store At Least One": "至少保留一个存储",
|
||||
"Submit Error": "提交表单错误",
|
||||
"Such as": "例如: ",
|
||||
"Terminal": "终端",
|
||||
"The Cpu target is empty": "CPU 目标值为空",
|
||||
"The application name can contain only lowercase letters, digits, and hyphens (-) and must start with a letter": "应用名称只能包含小写字母、数字和连字符(-),且必须以字母开头",
|
||||
"The cpu target value must be positive": "CPU 目标值必须为正数",
|
||||
"The image cannot be empty": "私有镜像地址不能为空",
|
||||
"The maximum number of instances is 20": "实例数最大为20",
|
||||
"The minimum number of instances is 1": "实例数最小为1",
|
||||
"The number of instances cannot be empty": "实例数不能为空",
|
||||
"The password cannot be empty": "密码不能为空",
|
||||
"The target cpu value must be less than 100": "CPU 目标值须小于 100",
|
||||
"The user name cannot be empty": "用户名不能为空",
|
||||
"To Confirm": "确认",
|
||||
"Total": "总计",
|
||||
"TotalPrice": "总价",
|
||||
"Under Stock": "库存不足",
|
||||
"Update": "变更",
|
||||
"Update Application": "更新应用",
|
||||
"Update Failed": "更新失败",
|
||||
"Update Successful": "更新成功",
|
||||
"Update Time": "更新时间",
|
||||
"UpdateTime": "更新时间",
|
||||
"Username": "用户名",
|
||||
"Username for the image registry": "镜像仓库用户名",
|
||||
"Waiting": "获取中",
|
||||
"Warning": "提示",
|
||||
"analysis_error": "智能分析出错了~",
|
||||
"Annotations": "注解",
|
||||
"Controlled By": "受控于",
|
||||
"Count": "次数",
|
||||
"First Seen": "首次发生",
|
||||
"Last Occur": "最后发生",
|
||||
"Labels": "标签",
|
||||
"Message": "信息",
|
||||
"Reason": "原因",
|
||||
"YAML File": "YAML 文件",
|
||||
"Yes": "确认",
|
||||
"You can enter the container through the terminal": "您可以通过终端进入容器",
|
||||
"You have successfully deployed an application": "您已成功部署一个应用",
|
||||
"You haven't created any application yet": "您尚未创建任何应用",
|
||||
"add_credit": "充值",
|
||||
"advanced_filter": "高级筛选",
|
||||
"all": "全部",
|
||||
"terminated": "Terminated",
|
||||
"app": {
|
||||
"Resource Quota": "资源配额",
|
||||
"The applied CPU exceeds the quota": "申请的 CPU 超出配额限制,请联系管理员",
|
||||
"The applied GPU exceeds the quota": "申请的 GPU 超出配额限制,请联系管理员",
|
||||
"The applied memory exceeds the quota": "申请的 '内存' 超出配额限制,请联系管理员",
|
||||
"The applied nodeports exceeds the quota": "申请的 '外网端口' 超出配额限制,请联系管理员",
|
||||
"The applied storage exceeds the quota": "申请的 '存储' 超出配额限制,请联系管理员",
|
||||
"The container exposed port cannot be empty": "容器暴露端口不能为空",
|
||||
"The maximum number of exposed ports is 65535": "暴露端口最大为 65535",
|
||||
"The minimum exposed port is 1": "暴露端口最小为 1"
|
||||
},
|
||||
"app_already_exists": "应用名称已被使用,请更换应用名称或前往应用列表查看现有应用",
|
||||
"app_store": "应用商店",
|
||||
"application_source": "来源",
|
||||
"balance": "余额",
|
||||
"capacity": "容量",
|
||||
"click_to_copy_tooltip": "点击复制",
|
||||
"close": "关闭",
|
||||
"common": {
|
||||
"Surplus": "剩余",
|
||||
"Used": "已用"
|
||||
},
|
||||
"confirm_to_go": "确认前往",
|
||||
"contains": "包含",
|
||||
"cpu": "CPU",
|
||||
"pod": "Pod",
|
||||
"cpu_exceeds_quota": "CPU 资源不足:你设置的 {{requested}} 核心超出工作空间可用 {{limit}} 核限制(已使用 {{used}} 核)。",
|
||||
"custom_domain_input_title": "您的域名",
|
||||
"day": "天",
|
||||
"delete_app_tip": "确定要删除此应用吗?",
|
||||
"delete_warning_persistent": "如果继续,该项目的所有数据将被删除。",
|
||||
"delete_sealaf_app_tip": "该应用是通过云开发部署的。仅删除应用无法清除应用的所有组件,这些组件可能仍会产生费用。要彻底卸载应用并清理所有相关组件,请在云开发中卸载整个应用。",
|
||||
"delete_template_app_tip": "该应用由应用商店部署与管理,即将前往应用商店删除该应用全部组件。",
|
||||
"domain_challenge_verify_failed_toast": "域名挑战验证失败",
|
||||
"domain_cname_verify_failed_toast": "CNAME 记录验证失败",
|
||||
"domain_input_tip": "请自备域名并填入下方输入框中。",
|
||||
"domain_invalid_toast": "'{{domain}}' 不是有效的域名",
|
||||
"domain_cannot_use_internal": "不能使用内部域名",
|
||||
"domain_not_bound_modal_cancel": "取消",
|
||||
"domain_not_bound_modal_confirm": "确认",
|
||||
"domain_not_bound_modal_description": "您确定要关闭这个弹窗吗?",
|
||||
"domain_not_bound_modal_title": "域名未绑定",
|
||||
"domain_not_bound_modal_warning": "DNS 记录尚未生效,因此您的域名未完成绑定。关闭此弹窗需重新输入域名。",
|
||||
"domain_registration_provider_link_text": "备案入口",
|
||||
"domain_registration_query_link_text": "备案查询",
|
||||
"domain_requires_registration_tip_1": "请自备域名并通过 ",
|
||||
"domain_requires_registration_tip_2": " 完成备案后,将域名填入下方输入框中。",
|
||||
"domain_verification_dns_records": "DNS 记录",
|
||||
"domain_verification_dns_records_docs_link_text": "查看文档",
|
||||
"domain_verification_dns_records_tip_1": "请到您的域名服务商处,添加该域名的 CNAME 解析到 ",
|
||||
"domain_verification_dns_records_tip_2": ",解析生效后即可绑定自定义域名。",
|
||||
"domain_verification_dns_records_ttl": "TTL",
|
||||
"domain_verification_dns_records_ttl_auto": "自动",
|
||||
"domain_verification_dns_records_type": "类型",
|
||||
"domain_verification_dns_records_type_cname": "CNAME",
|
||||
"domain_verification_dns_records_value": "值",
|
||||
"domain_verification_input_clear": "解绑",
|
||||
"domain_verification_input_edit": "编辑",
|
||||
"domain_verification_input_save": "保存",
|
||||
"domain_verification_needed": "待验证",
|
||||
"domain_verification_refresh": "刷新",
|
||||
"domain_verification_success": "您的域名已成功绑定。",
|
||||
"domain_verified": "验证通过",
|
||||
"download": "下载",
|
||||
"driver": {
|
||||
"access_application": "访问应用",
|
||||
"click_anywhere": "点击任意位置完成教程",
|
||||
"configure_launchpad": "配置启动台",
|
||||
"copy_address": "复制私有或公共地址以进行访问",
|
||||
"create_launchpad": "创建应用",
|
||||
"create_launchpad_header": "请随意探索。本演示指南不会保存任何更改,也不会收取任何费用。",
|
||||
"define_image_settings": "定义镜像设置,并按需调整 CPU 和内存",
|
||||
"define_settings": "定义您的应用设置并开始部署",
|
||||
"find_guide_tip": "你可以随时在顶部导航栏重新打开本指南,祝你探索愉快!",
|
||||
"got_it": "明白",
|
||||
"next": "下一步",
|
||||
"quit_guide": "退出指南",
|
||||
"still_here": "我们还在这里!"
|
||||
},
|
||||
"end": "结束",
|
||||
"equal": "等于",
|
||||
"export_log": "导出日志",
|
||||
"field_name": "字段名",
|
||||
"field_settings": "字段设置",
|
||||
"file": "文件",
|
||||
"file value": "文件值",
|
||||
"filename": "文件名",
|
||||
"filter": "筛选",
|
||||
"first_charge": "首充优惠",
|
||||
"first_charge_tip": "部分规格首次充值可享双倍赠送金额",
|
||||
"forbidden_create_app": "您在当前工作空间没有足够的权限来创建应用。请联系该工作空间的 Owner (所有者) 为您授权。",
|
||||
"form": {
|
||||
"add_configmap": "新增配置文件",
|
||||
"storage_path_placeholder": "如:/data"
|
||||
},
|
||||
"gift": "赠",
|
||||
"gift amount tip": "充值 {{amount}} 赠送 {{gift}} ",
|
||||
"gift time tip": "{{time}} 小时内",
|
||||
"gpu": "GPU",
|
||||
"gpu_exceeds_quota": "GPU 资源不足:你设置的 {{requested}} 个 GPU 超出工作空间可用 {{limit}} 个 GPU 限制(已使用 {{used}} 个 GPU)。",
|
||||
"grpcs": "gRPCS",
|
||||
"guide_deploy_button": "完成创建",
|
||||
"guide_deploy_command": "丰富的命令集支持,简化服务器管理",
|
||||
"guide_deploy_storage": "海量存储,满足各种应用需求",
|
||||
"guide_detail_monitor": "可视化资源监控",
|
||||
"guide_detail_network": "提供内网和外网访问地址,并自动配置 SSL 证书",
|
||||
"guide_detail_operate": "日志|终端|详情|重启",
|
||||
"guide_detail_update_button": "按需实时调整",
|
||||
"hidden": "隐藏",
|
||||
"hour": "小时",
|
||||
"hour-singular": "小时",
|
||||
"https": "HTTPS",
|
||||
"icp_reg_not_registered": "域名未备案",
|
||||
"infrastructure": {
|
||||
"providers": {
|
||||
"alibaba": {
|
||||
"domain_reg_link": "https://beian.aliyun.com/pcContainer/selfEntity",
|
||||
"name": "阿里云"
|
||||
},
|
||||
"tencent": {
|
||||
"domain_reg_link": "https://cloud.tencent.com/product/ba",
|
||||
"name": "腾讯云"
|
||||
},
|
||||
"volcano": {
|
||||
"domain_reg_link": "https://console.volcengine.com/beian/overview/",
|
||||
"name": "火山引擎"
|
||||
}
|
||||
}
|
||||
},
|
||||
"json_mode": "JSON模式",
|
||||
"jump_message": "该应用不允许单独使用,点击确认前往 Sealos Desktop 使用。",
|
||||
"jump_prompt": "跳转提示",
|
||||
"keyword": "关键词",
|
||||
"let me think again": "我再想想",
|
||||
"logNumber": "日志数量",
|
||||
"log_number": "日志数",
|
||||
"log_table": {
|
||||
"_msg": "日志内容",
|
||||
"_time": "时间",
|
||||
"container": "容器",
|
||||
"pod": "Pod",
|
||||
"stream": "输出流"
|
||||
},
|
||||
"logs": "日志",
|
||||
"memory": "内存",
|
||||
"memory_exceeds_quota": "内存资源不足:你设置的 {{requested}} GB 超出工作空间可用 {{limit}} GB 限制(已使用 {{used}} GB)。",
|
||||
"minute": "分钟",
|
||||
"monitor": "监控",
|
||||
"monitor_tab_network": "网络",
|
||||
"monitor_tab_performance": "性能",
|
||||
"monitor_tab_storage": "存储",
|
||||
"storage_usage": "存储使用",
|
||||
"network_request_count": "网络请求数量",
|
||||
"network_request_percent": "网络请求占比",
|
||||
"no_network_configured": "未配置网络",
|
||||
"instance": "实例",
|
||||
"select_instance_service_port": "选择实例(service:port)",
|
||||
"select_port": "选择端口",
|
||||
"mount path": "挂载路径",
|
||||
"network_not_ready": "内部网关需要 2~5 分钟准备,请耐心等待",
|
||||
"network_not_ready_icp_reg": "若域名已备案但仍显示“准备中”,请进一步检查是否在当前可用区的供应商备案。",
|
||||
"new filename": "重命名为",
|
||||
"no_data_available": "暂无数据",
|
||||
"no_logs_for_now": "暂无日志",
|
||||
"nodeport": "端口",
|
||||
"nodeport_exceeds_quota": "端口资源不足:你设置的 {{requested}} 个端口超出工作空间可用 {{limit}} 个端口限制(已使用 {{used}} 个端口)。",
|
||||
"nodeports": "外网端口",
|
||||
"normal_filter": "普通筛选",
|
||||
"not_contains": "不包含",
|
||||
"not_equal": "不等于",
|
||||
"highlight_stderr": "高亮 Stderr",
|
||||
"or": "或",
|
||||
"original filename": "原文件名",
|
||||
"overview": "概览",
|
||||
"pause_message": "暂停服务将停止计算 CPU 和内存等费用,但存储和外网端口仍将产生费用。是否现在暂停?",
|
||||
"pending_to_allocated": "待分配",
|
||||
"piece": "个",
|
||||
"please enter app name": "请输入:{{appName}}",
|
||||
"please_select": "请选择",
|
||||
"please_upgrade_plan": {
|
||||
"0": "请 ",
|
||||
"1": "升级您的计划",
|
||||
"2": " 或调整设置"
|
||||
},
|
||||
"private": "私有",
|
||||
"proxy_detected_message": "使用代理会降低 Sealos 的性能。要禁用代理,请更新您的 DNS 提供商设置。",
|
||||
"proxy_detected_title": "检测到代理",
|
||||
"public": "公共",
|
||||
"receive": "已获得",
|
||||
"recently": "最近",
|
||||
"refetching_success": "刷新成功",
|
||||
"refresh": "刷新",
|
||||
"refresh_interval": "刷新间隔",
|
||||
"remark_updated_successfully": "备注更新成功",
|
||||
"remarks": "备注",
|
||||
"remind": "提醒",
|
||||
"rename": "重命名",
|
||||
"Rename Success": "重命名成功",
|
||||
"Rename Failed": "重命名失败",
|
||||
"sealaf": "云开发",
|
||||
"search": "查询",
|
||||
"selected": "已选中",
|
||||
"set_remarks": "设置备注",
|
||||
"set_remarks_placeholder": "设置备注以帮助更轻松地识别此应用或服务。",
|
||||
"set_remarks_title": "设置备注",
|
||||
"shared": "共享",
|
||||
"show hidden files": "显示隐藏文件",
|
||||
"skip": "跳过",
|
||||
"start": "开始",
|
||||
"storage": "存储卷",
|
||||
"streaming_logs": "实时日志",
|
||||
"success": "成功",
|
||||
"target_value": "目标值",
|
||||
"terminated_logs": "中断前",
|
||||
"time": "时间",
|
||||
"time_zone": "时区",
|
||||
"total_price_tip": "预估费用不包括端口费用和流量费用,以实际使用为准",
|
||||
"traffic": "流量",
|
||||
"update_remark_failed": "更新备注失败",
|
||||
"update_sealaf_app_tip": "该应用是通过云开发部署的,变更该应用请到云开发中进行。",
|
||||
"upload": "上传",
|
||||
"user": {
|
||||
"Insufficient account balance": "账户余额不足~"
|
||||
},
|
||||
"user_balance_not_enough": "快完成了!您需要额外积分才能完成此部署。",
|
||||
"value": "值",
|
||||
"visible": "可见",
|
||||
"vm": "显存",
|
||||
"websocket": "WebSocket",
|
||||
"within_1_day": "一天内",
|
||||
"within_1_hour": "一小时内",
|
||||
"within_5_minute": "五分钟内",
|
||||
"No Applications": "暂无应用",
|
||||
"create_your_first_application": "部署您的第一个应用",
|
||||
"click_here_to_create_application": "点击此处从 Docker 镜像部署应用,仅需几步即可完成",
|
||||
"pod_filtering": "Pod 筛选",
|
||||
"container_filtering": "容器筛选",
|
||||
"Refresh": "刷新",
|
||||
"auto_refresh": "自动刷新",
|
||||
"no_auto_refresh": "自动刷新: 关闭",
|
||||
"set_automatic_refresh": "设置自动刷新",
|
||||
"Probe": "探针",
|
||||
"Startup": "启动探针",
|
||||
"Liveness": "存活探针",
|
||||
"Readiness": "就绪探针",
|
||||
"No startup added yet": "暂未添加启动探针",
|
||||
"No liveness added yet": "暂未添加存活探针",
|
||||
"No readiness added yet": "暂未添加就绪探针",
|
||||
"replica_filtering": "副本筛选",
|
||||
"volume_filtering": "卷筛选",
|
||||
"status_filtering": "状态筛选",
|
||||
"show_all": "全部显示"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<svg width="78" height="78" viewBox="0 0 78 78" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1172_18725)">
|
||||
<rect width="78" height="78" fill="url(#paint0_linear_1172_18725)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.3611 47.639C30.3281 47.6056 30.295 47.5722 30.2617 47.5389C28.2185 45.4956 25.7827 44.0841 23.1971 43.3042L23.1926 43.3027C22.6012 43.113 22.0001 42.9547 21.3919 42.8286L20.9102 42.7169C19.6234 42.4152 18.3299 42.0459 17.4511 41.1567C15.9811 39.6755 16.7512 37.3263 17.4999 35.5873L17.9141 34.6575L18.2598 33.9369C18.9933 32.4658 20.0137 30.8934 21.5531 30.4578C22.5441 30.1783 23.5601 30.2201 24.5776 30.262L25.239 30.2883C25.6816 30.2997 26.1241 30.2993 26.5623 30.2571C32.2225 22.1463 39.5002 18.9393 45.5913 17.9795C48.6776 17.4927 51.4539 17.5823 53.5723 17.8854L54.4708 18.02C55.8674 18.2477 57.431 18.6252 58.3886 19.5939L58.3974 19.6027L58.4062 19.6114C59.3749 20.5691 59.7524 22.1327 59.9801 23.5293L60.1146 24.4278C60.4178 26.5462 60.5074 29.3224 60.0206 32.4088C59.0608 38.4999 55.8538 45.7776 47.743 51.4378C47.7007 51.8759 47.7004 52.3185 47.7118 52.7611L47.7381 53.4233C47.78 54.4408 47.8218 55.4559 47.5422 56.4469C47.1067 57.9863 45.5343 59.0068 44.0632 59.7403L43.3426 60.086L42.4128 60.5002C40.6738 61.2489 38.3246 62.019 36.8434 60.5489C35.9542 59.6702 35.5849 58.3767 35.2832 57.0899L35.1715 56.6082C35.0454 56 34.8871 55.3989 34.6974 54.8074L34.6959 54.803C33.916 52.2174 32.5045 49.7816 30.4612 47.7383C30.4279 47.7051 30.3945 47.672 30.3611 47.639ZM46.8173 31.0833C46.8338 31.0999 46.8502 31.1167 46.8665 31.1336C46.8752 31.1421 46.884 31.1505 46.8927 31.159C46.9007 31.1669 46.9088 31.1748 46.9168 31.1828C47.7943 32.0526 48.2904 33.2355 48.2958 34.4711C48.3012 35.7067 47.8156 36.8938 46.9457 37.7714C46.0758 38.6489 44.893 39.1449 43.6574 39.1504C42.4218 39.1558 41.2347 38.6701 40.3571 37.8003C40.3308 37.7741 40.3047 37.7477 40.2791 37.721C40.2524 37.6953 40.2259 37.6693 40.1998 37.643C39.33 36.7654 38.8443 35.5783 38.8497 34.3427C38.8552 33.1071 39.3512 31.9243 40.2287 31.0544C41.1063 30.1845 42.2934 29.6989 43.529 29.7043C44.7646 29.7097 45.9474 30.2058 46.8173 31.0833ZM19.7336 60.8987C23.0033 59.5729 27.2365 57.7217 28.3477 56.6105C30.2248 54.7333 30.2248 51.6898 28.3477 49.8126C26.4705 47.9354 23.427 47.9354 21.5498 49.8126C20.4386 50.9238 18.5874 55.157 17.2616 58.4267C16.6196 60.01 18.1503 61.5406 19.7336 60.8987Z" fill="url(#paint1_linear_1172_18725)"/>
|
||||
<circle cx="43.4963" cy="34.5035" r="4.66225" transform="rotate(75 43.4963 34.5035)" fill="url(#paint2_linear_1172_18725)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1172_18725" x1="78" y1="0" x2="0" y2="78" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A5B2F2"/>
|
||||
<stop offset="1" stop-color="#546DEB"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_1172_18725" x1="58.3972" y1="19.6029" x2="27.1469" y2="50.8531" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_1172_18725" x1="38.834" y1="34.5035" x2="48.1585" y2="34.5035" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2D3351"/>
|
||||
<stop offset="1" stop-color="#808BC5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_1172_18725">
|
||||
<rect width="78" height="78" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1,579 +0,0 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "Sealos AppLaunchpad API",
|
||||
"version": "1.0.0",
|
||||
"description": "API documentation for Sealos AppLaunchpad"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:3000",
|
||||
"description": "Development"
|
||||
},
|
||||
{
|
||||
"url": "https://applaunchpad.gzg.sealos.run",
|
||||
"description": "Production"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"kubeconfigAuth": []
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/v1alpha/createApp": {
|
||||
"post": {
|
||||
"summary": "Create a new application",
|
||||
"description": "Create a new application with the specified configuration",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"appForm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"appName": {
|
||||
"type": "string",
|
||||
"default": "hello-world"
|
||||
},
|
||||
"imageName": {
|
||||
"type": "string",
|
||||
"default": "nginx"
|
||||
},
|
||||
"runCMD": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"cmdParam": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"replicas": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"default": 1
|
||||
},
|
||||
"cpu": {
|
||||
"type": "number",
|
||||
"default": 200
|
||||
},
|
||||
"memory": {
|
||||
"type": "number",
|
||||
"default": 256
|
||||
},
|
||||
"gpu": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"manufacturers": {
|
||||
"type": "string",
|
||||
"default": "nvidia"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"networks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"networkName": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"portName": {
|
||||
"type": "string",
|
||||
"default": "zqpqdcalxjeq"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"default": 80
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"TCP",
|
||||
"UDP",
|
||||
"SCTP"
|
||||
],
|
||||
"default": "TCP"
|
||||
},
|
||||
"appProtocol": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"HTTP",
|
||||
"GRPC",
|
||||
"WS"
|
||||
],
|
||||
"default": "HTTP"
|
||||
},
|
||||
"openPublicDomain": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"publicDomain": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"customDomain": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"nodePort": {
|
||||
"type": "number"
|
||||
},
|
||||
"openNodePort": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": [
|
||||
{
|
||||
"networkName": "",
|
||||
"portName": "sscvofyimjui",
|
||||
"port": 80,
|
||||
"protocol": "TCP",
|
||||
"appProtocol": "HTTP",
|
||||
"openPublicDomain": false,
|
||||
"openNodePort": false,
|
||||
"publicDomain": "",
|
||||
"customDomain": "",
|
||||
"domain": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"envs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueFrom": {}
|
||||
},
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"hpa": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"use": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cpu",
|
||||
"memory",
|
||||
"gpu"
|
||||
],
|
||||
"default": "cpu"
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"default": 50
|
||||
},
|
||||
"minReplicas": {
|
||||
"type": "number",
|
||||
"default": 1
|
||||
},
|
||||
"maxReplicas": {
|
||||
"type": "number",
|
||||
"default": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"secret": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"use": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"serverAddress": {
|
||||
"type": "string",
|
||||
"default": "docker.io"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configMapList": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mountPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mountPath",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"storeList": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"volumes": {
|
||||
"type": "array",
|
||||
"items": {},
|
||||
"default": []
|
||||
},
|
||||
"volumeMounts": {
|
||||
"type": "array",
|
||||
"items": {},
|
||||
"default": []
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"deployment",
|
||||
"statefulset"
|
||||
],
|
||||
"default": "deployment"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hpa",
|
||||
"secret"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appForm"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Application created successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"default": "success"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "number"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "number"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1alpha/getAppByAppName": {
|
||||
"get": {
|
||||
"summary": "Get application by name",
|
||||
"description": "Retrieve application details by application name",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "appName",
|
||||
"in": "query",
|
||||
"description": "Application name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Application details retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"nullable": true,
|
||||
"items": {}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid query parameters",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "number"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "number"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1alpha/delAppByName": {
|
||||
"delete": {
|
||||
"summary": "Delete application",
|
||||
"description": "Delete an application by its name",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "query",
|
||||
"description": "Name of the application to delete",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Application deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid query parameters",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "number"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "number"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"kubeconfigAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "Authorization",
|
||||
"description": "Kubeconfig for authentication"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { GET, POST, DELETE } from '@/services/request';
|
||||
import type { V1Deployment, SinglePodMetrics, V1StatefulSet } from '@kubernetes/client-node';
|
||||
import { adaptAppListItem, adaptMetrics, adaptEvents } from '@/utils/adapt';
|
||||
import type { AppDetailType, AppPatchPropsType, PodDetailType } from '@/types/app';
|
||||
import { MonitorDataResult, MonitorQueryKey } from '@/types/monitor';
|
||||
import { LogQueryPayload } from '@/pages/api/log/queryLogs';
|
||||
import { PodListQueryPayload } from '@/pages/api/log/queryPodList';
|
||||
import { NetworkMonitorDataResult } from '@/services/networkMonitorFetch';
|
||||
import { track } from '@sealos/gtm';
|
||||
import { Quantity } from '@sealos/shared';
|
||||
import { quantityFromJSONOrZero } from '@/utils/resourceQuantity';
|
||||
|
||||
export const postDeployApp = (yamlList: string[], mode: 'create' | 'replace' = 'create') =>
|
||||
POST('/api/applyApp', { yamlList, mode });
|
||||
|
||||
export const putApp = (data: {
|
||||
patch: AppPatchPropsType;
|
||||
appName: string;
|
||||
stateFulSetYaml?: string;
|
||||
}) => {
|
||||
track('deployment_update', {
|
||||
module: 'applaunchpad'
|
||||
});
|
||||
|
||||
return POST('/api/updateApp', data);
|
||||
};
|
||||
|
||||
export const getMyApps = () =>
|
||||
GET<V1Deployment & V1StatefulSet[]>('/api/getApps').then((res) => res.map(adaptAppListItem));
|
||||
|
||||
export const delAppByName = (name: string) => {
|
||||
track('deployment_delete', {
|
||||
module: 'applaunchpad'
|
||||
});
|
||||
|
||||
return DELETE('/api/delApp', { name });
|
||||
};
|
||||
|
||||
const hydrateQuantity = (value: unknown): Quantity =>
|
||||
value instanceof Quantity ? value : quantityFromJSONOrZero(value);
|
||||
|
||||
const hydrateAppDetail = (app: AppDetailType): AppDetailType => ({
|
||||
...app,
|
||||
cpu: hydrateQuantity(app.cpu),
|
||||
memory: hydrateQuantity(app.memory),
|
||||
storeList: app.storeList.map((store) => ({
|
||||
...store,
|
||||
value: hydrateQuantity(store.value)
|
||||
}))
|
||||
});
|
||||
|
||||
const hydratePodDetail = (pod: PodDetailType): PodDetailType => ({
|
||||
...pod,
|
||||
cpu: hydrateQuantity(pod.cpu),
|
||||
memory: hydrateQuantity(pod.memory)
|
||||
});
|
||||
|
||||
export const getAppByName = (name: string, mock = false) =>
|
||||
GET<AppDetailType>(`/api/getAppByAppName?appName=${name}&mock=${mock}`).then(hydrateAppDetail);
|
||||
|
||||
export const getAppPodsByAppName = (name: string) =>
|
||||
GET<PodDetailType[]>('/api/getAppPodsByAppName', { name }).then((pods) =>
|
||||
pods.map(hydratePodDetail)
|
||||
);
|
||||
|
||||
export const getPodsMetrics = (podsName: string[]) =>
|
||||
POST<SinglePodMetrics[]>('/api/getPodsMetrics', { podsName }).then((item) =>
|
||||
item.map(adaptMetrics)
|
||||
);
|
||||
|
||||
export const getPodLogs = (data: {
|
||||
appName: string;
|
||||
podName: string;
|
||||
containerName?: string;
|
||||
stream: boolean;
|
||||
logSize?: number;
|
||||
sinceTime?: number;
|
||||
previous?: boolean;
|
||||
}) => POST<string>(`/api/getPodLogs`, data);
|
||||
|
||||
export const getPodEvents = (podName: string) =>
|
||||
GET(`/api/getPodEvents?podName=${podName}`).then(adaptEvents);
|
||||
|
||||
export const restartAppByName = (appName: string) => {
|
||||
track('deployment_restart', {
|
||||
module: 'applaunchpad'
|
||||
});
|
||||
|
||||
return GET(`/api/restartApp?appName=${appName}`);
|
||||
};
|
||||
|
||||
export const pauseAppByName = (appName: string) => {
|
||||
track('deployment_shutdown', {
|
||||
module: 'applaunchpad',
|
||||
type: 'normal'
|
||||
});
|
||||
|
||||
return GET(`/api/pauseApp?appName=${appName}`);
|
||||
};
|
||||
|
||||
export const startAppByName = (appName: string) => {
|
||||
track('deployment_start', {
|
||||
module: 'applaunchpad'
|
||||
});
|
||||
|
||||
return GET(`/api/startApp?appName=${appName}`);
|
||||
};
|
||||
|
||||
export const restartPodByName = (podName: string) => GET(`/api/restartPod?podName=${podName}`);
|
||||
|
||||
interface MonitorApiResponseV2 {
|
||||
result: MonitorDataResult[];
|
||||
debug?: {
|
||||
requestParams?: any;
|
||||
rawResponse?: any;
|
||||
adaptedData?: any;
|
||||
finalResult?: any;
|
||||
};
|
||||
}
|
||||
|
||||
export const getAppMonitorData = async (payload: {
|
||||
queryName: string;
|
||||
queryKey: keyof MonitorQueryKey;
|
||||
step: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
pvcName?: string;
|
||||
}): Promise<MonitorDataResult[]> => {
|
||||
try {
|
||||
const response = await GET<MonitorApiResponseV2>(`/api/monitor/getMonitorDataV2`, payload);
|
||||
|
||||
if (response && typeof response === 'object' && 'result' in response) {
|
||||
const { debug, result } = response as MonitorApiResponseV2;
|
||||
if (debug) {
|
||||
}
|
||||
return result || [];
|
||||
}
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
interface NetworkMonitorApiResponse {
|
||||
result: NetworkMonitorDataResult[];
|
||||
debug?: {
|
||||
requestParams?: any;
|
||||
rawResponse?: any;
|
||||
adaptedData?: any;
|
||||
finalResult?: any;
|
||||
};
|
||||
}
|
||||
|
||||
export const getNetworkMonitorData = async (payload: {
|
||||
serviceName: string;
|
||||
port: number;
|
||||
type: 'network_service_request_count' | 'network_service_request_percent';
|
||||
step: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
}): Promise<NetworkMonitorDataResult[]> => {
|
||||
try {
|
||||
const response = await GET<NetworkMonitorApiResponse>(
|
||||
`/api/monitor/getNetworkMonitorData`,
|
||||
payload
|
||||
);
|
||||
|
||||
if (response && typeof response === 'object' && 'result' in response) {
|
||||
const { debug, result } = response as NetworkMonitorApiResponse;
|
||||
if (debug) {
|
||||
}
|
||||
return result || [];
|
||||
}
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getAppLogs = (payload: LogQueryPayload) => POST<string>('/api/log/queryLogs', payload);
|
||||
|
||||
export const getLogPodList = (payload: PodListQueryPayload) =>
|
||||
POST<string[]>('/api/log/queryPodList', payload);
|
||||
|
||||
export const setAppRemark = (payload: {
|
||||
appName: string;
|
||||
remark: string;
|
||||
kind: 'deployment' | 'statefulset';
|
||||
}) => POST('/api/remark', payload);
|
||||
@@ -1,53 +0,0 @@
|
||||
import { POST } from '@/services/request';
|
||||
import { TFile } from '@/utils/kubeFileSystem';
|
||||
import { AxiosProgressEvent } from 'axios';
|
||||
|
||||
export const kubeFile_ls = (payload: {
|
||||
containerName: string;
|
||||
podName: string;
|
||||
path: string;
|
||||
showHidden: boolean;
|
||||
}) =>
|
||||
POST<{
|
||||
directories: TFile[];
|
||||
files: TFile[];
|
||||
}>('/api/kubeFileSystem/ls', payload);
|
||||
|
||||
export const kubeFile_download = (payload: {
|
||||
containerName: string;
|
||||
podName: string;
|
||||
path: string;
|
||||
}) => POST('/api/kubeFileSystem/download', payload);
|
||||
|
||||
export const kubeFile_rename = (payload: {
|
||||
containerName: string;
|
||||
podName: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}) => POST('/api/kubeFileSystem/mv', payload);
|
||||
|
||||
export const kubeFile_delete = (payload: {
|
||||
containerName: string;
|
||||
podName: string;
|
||||
path: string;
|
||||
}) => POST('/api/kubeFileSystem/rm', payload);
|
||||
|
||||
export const kubeFile_upload = (
|
||||
payload: {
|
||||
containerName: string;
|
||||
podName: string;
|
||||
path: string;
|
||||
},
|
||||
formData: FormData,
|
||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
|
||||
) =>
|
||||
POST(`/api/kubeFileSystem/upload`, formData, {
|
||||
params: payload,
|
||||
onUploadProgress,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
export const kubeFile_mkdir = (payload: { containerName: string; podName: string; path: string }) =>
|
||||
POST(`/api/kubeFileSystem/mkdir`, payload);
|
||||
@@ -1,8 +0,0 @@
|
||||
export type AuthCnamePrams = {
|
||||
publicDomain: string;
|
||||
customDomain: string;
|
||||
};
|
||||
|
||||
export type AuthDomainChallengeParams = {
|
||||
customDomain: string;
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { GET, POST } from '@/services/request';
|
||||
import type { UserTask, userPriceType } from '@/types/user';
|
||||
import { getUserSession } from '@/utils/user';
|
||||
import { AuthCnamePrams, AuthDomainChallengeParams } from './params';
|
||||
import { CheckICPRegParams } from '@/pages/api/platform/checkIcpReg';
|
||||
|
||||
export const getResourcePrice = () => GET<userPriceType>('/api/platform/resourcePrice');
|
||||
|
||||
export const postAuthCname = (data: AuthCnamePrams) => POST('/api/platform/authCname', data);
|
||||
|
||||
export const postAuthDomainChallenge = (data: AuthDomainChallengeParams) =>
|
||||
POST<{
|
||||
verified: boolean;
|
||||
domain: string;
|
||||
challengeUrl: string;
|
||||
proxy: {
|
||||
isProxy: boolean;
|
||||
proxyType?: string;
|
||||
details?: any;
|
||||
};
|
||||
}>('/api/platform/authDomainChallenge', data);
|
||||
|
||||
export const getUserTasks = () =>
|
||||
GET<{ needGuide: boolean; task: UserTask }>('/api/guide/getTasks', undefined, {
|
||||
headers: {
|
||||
Authorization: getUserSession()?.token
|
||||
}
|
||||
});
|
||||
|
||||
export const checkUserTask = () =>
|
||||
GET('/api/guide/checkTask', undefined, {
|
||||
headers: {
|
||||
Authorization: getUserSession()?.token
|
||||
}
|
||||
});
|
||||
|
||||
export const checkPermission = (payload: { appName: string }) =>
|
||||
GET('/api/platform/checkPermission', payload);
|
||||
|
||||
export const checkReady = (appName: string) =>
|
||||
GET<{ url: string; ready: boolean; error?: string }[]>(`/api/checkReady?appName=${appName}`);
|
||||
|
||||
export const checkDomainICP = (data: CheckICPRegParams) =>
|
||||
POST<{
|
||||
domain: string;
|
||||
icpRegistered: boolean;
|
||||
}>('/api/platform/checkIcpReg', data);
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* obj to query
|
||||
*/
|
||||
export const obj2Query = (obj: Record<string, string | number>) => {
|
||||
let str = '';
|
||||
Object.entries(obj).forEach(([key, val]) => {
|
||||
if (val) {
|
||||
str += `${key}=${val}&`;
|
||||
}
|
||||
});
|
||||
|
||||
return str.slice(0, str.length - 1);
|
||||
};
|
||||
@@ -1,208 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import React, { useRef, forwardRef, useMemo, useState } from 'react';
|
||||
import { Button } from '@sealos/shadcn-ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@sealos/shadcn-ui/popover';
|
||||
import { Checkbox } from '@sealos/shadcn-ui/checkbox';
|
||||
import { Separator } from '@sealos/shadcn-ui/separator';
|
||||
import { cn } from '@sealos/shadcn-ui';
|
||||
|
||||
export interface ListItem {
|
||||
label: string | React.ReactNode;
|
||||
value: string;
|
||||
checked: boolean;
|
||||
isActive?: boolean; // Whether the item is active (e.g., active pod vs historical pod)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
width?: string;
|
||||
minW?: string;
|
||||
height?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
list: ListItem[];
|
||||
onchange?: (val: string) => void;
|
||||
onCheckboxChange?: (list: ListItem[]) => void;
|
||||
isInvalid?: boolean;
|
||||
checkBoxMode?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AdvancedSelect = (
|
||||
{
|
||||
placeholder,
|
||||
leftIcon,
|
||||
value,
|
||||
width = 'auto',
|
||||
minW = 'auto',
|
||||
height = '32px',
|
||||
list,
|
||||
onchange,
|
||||
onCheckboxChange,
|
||||
isInvalid,
|
||||
checkBoxMode = false,
|
||||
title,
|
||||
className
|
||||
}: Props,
|
||||
selectRef: any
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const displayText = useMemo(() => {
|
||||
const selectedCount = checkBoxMode ? list.filter((item) => item.checked).length : 0;
|
||||
const activeMenu = list.find((item) => item.value === value);
|
||||
|
||||
if (!checkBoxMode) {
|
||||
return activeMenu ? activeMenu.label : placeholder;
|
||||
}
|
||||
if (selectedCount === 0) {
|
||||
return placeholder;
|
||||
}
|
||||
if (selectedCount === list.length) {
|
||||
return t('all');
|
||||
}
|
||||
return `${selectedCount} ${t('selected')}`;
|
||||
}, [checkBoxMode, list, t, value, placeholder]);
|
||||
|
||||
const isPlaceholder = useMemo(() => {
|
||||
if (!checkBoxMode) {
|
||||
return !list.find((item) => item.value === value);
|
||||
}
|
||||
return list.filter((item) => item.checked).length === 0;
|
||||
}, [checkBoxMode, list, value]);
|
||||
|
||||
// Sort list: active items first, inactive items last
|
||||
const sortedList = useMemo(() => {
|
||||
return [...list].sort((a, b) => {
|
||||
const aActive = a.isActive !== false ? 1 : 0;
|
||||
const bActive = b.isActive !== false ? 1 : 0;
|
||||
return bActive - aActive;
|
||||
});
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={selectRef}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
'justify-start rounded-lg text-sm font-normal shadow-none hover:bg-zinc-50',
|
||||
isInvalid && 'border-red-500',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
width: width,
|
||||
minWidth: minW,
|
||||
height: height
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{leftIcon && (
|
||||
<>
|
||||
{leftIcon}
|
||||
<Separator orientation="vertical" className="!h-3 bg-zinc-300" />
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 text-left truncate',
|
||||
isPlaceholder ? 'text-zinc-500' : 'text-zinc-900'
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[220px] p-2 border-[0.5px] border-zinc-200 rounded-xl"
|
||||
align="start"
|
||||
>
|
||||
{title && (
|
||||
<div className="px-2 py-0.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto scrollbar-default">
|
||||
{checkBoxMode && (
|
||||
<label className="h-10 flex items-center space-x-2 px-2 py-1.5 rounded-lg hover:bg-zinc-50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={list.every((item) => item.checked)}
|
||||
onCheckedChange={() => {
|
||||
if (onCheckboxChange) {
|
||||
const newList = list.map((item) => ({
|
||||
...item,
|
||||
checked: !list.every((item) => item.checked)
|
||||
}));
|
||||
onCheckboxChange(newList);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-zinc-900 flex-1">{t('all')}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{sortedList.map((item, index) => (
|
||||
<div key={item.value + index}>
|
||||
{checkBoxMode ? (
|
||||
<label className="min-h-10 flex items-center space-x-2 px-2 py-1.5 rounded-lg hover:bg-zinc-50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={() => {
|
||||
if (onCheckboxChange) {
|
||||
const newList = list.map((listItem) =>
|
||||
listItem.value === item.value
|
||||
? { ...listItem, checked: !listItem.checked }
|
||||
: listItem
|
||||
);
|
||||
onCheckboxChange(newList);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm flex-1',
|
||||
item.isActive === false ? 'text-zinc-500' : 'text-zinc-900'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
{item.isActive === false && (
|
||||
<span className="ml-1 text-sm text-zinc-500">({t('terminated')})</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'h-10 flex items-center px-2 py-1.5 rounded-lg hover:bg-zinc-50 cursor-pointer text-sm',
|
||||
value === item.value && 'text-blue-600'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (onchange && value !== item.value) {
|
||||
onchange(item.value);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(AdvancedSelect);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { cn } from '@sealos/shadcn-ui';
|
||||
import type { AppStatusMapType } from '@/types/app';
|
||||
import { appStatusMap } from '@/constants/app';
|
||||
|
||||
interface AppStatusTagProps {
|
||||
status: AppStatusMapType;
|
||||
isPause: boolean;
|
||||
showBorder?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AppStatusTag = ({ status, isPause, showBorder = false, className }: AppStatusTagProps) => {
|
||||
const { t } = useTranslation();
|
||||
const statusMap = useMemo(() => (isPause ? appStatusMap.pause : status), [isPause, status]);
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className={cn('flex items-center gap-2 text-sm font-medium', className)}>
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-xs"
|
||||
style={{ backgroundColor: statusMap.dotColor }}
|
||||
/>
|
||||
<span>{t(statusMap.label)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppStatusTag;
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Button, ButtonProps, Flex, FlexProps, Text } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Icon, IconProps } from '@chakra-ui/react';
|
||||
|
||||
export function ToLeftIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12px"
|
||||
height="12px"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M5.414 5.99999L7.889 8.47499L7.182 9.18199L4 5.99999L7.182 2.81799L7.889 3.52499L5.414 5.99999Z"
|
||||
fill="#111824"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function RightFirstIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12px"
|
||||
height="12px"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M2.79492 3.705L5.08992 6L2.79492 8.295L3.49992 9L6.49992 6L3.49992 3L2.79492 3.705ZM7.99992 3H8.99992V9H7.99992V3Z"
|
||||
fill="#111824"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitchPage({
|
||||
totalPage,
|
||||
totalItem,
|
||||
pageSize,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
isPreviousData,
|
||||
...props
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPage: number;
|
||||
totalItem: number;
|
||||
pageSize: number;
|
||||
isPreviousData?: boolean;
|
||||
setCurrentPage: (idx: number) => void;
|
||||
} & FlexProps) {
|
||||
const { t } = useTranslation();
|
||||
const switchStyle: ButtonProps = {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
minW: '0',
|
||||
background: 'grayModern.250',
|
||||
flexGrow: '0',
|
||||
borderRadius: 'full',
|
||||
// variant:'unstyled',
|
||||
_hover: {
|
||||
background: 'grayModern.150',
|
||||
minW: '0'
|
||||
},
|
||||
_disabled: {
|
||||
borderRadius: 'full',
|
||||
background: 'grayModern.150',
|
||||
cursor: 'not-allowed',
|
||||
minW: '0'
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Flex minW="370px" h="32px" align={'center'} mb={'12px'} mr={'12px'} fontSize="14px" {...props}>
|
||||
<Text fontSize="14px" color={'grayModern.500'}>
|
||||
{t('Total')}:
|
||||
</Text>
|
||||
<Flex mr="25px" color={'grayModern.500'}>
|
||||
{totalItem}
|
||||
</Flex>
|
||||
<Flex gap={'8px'}>
|
||||
<Button
|
||||
{...switchStyle}
|
||||
isDisabled={currentPage === 1}
|
||||
bg={currentPage !== 1 ? 'grayModern.250' : 'grayModern.150'}
|
||||
p="0"
|
||||
minW="0"
|
||||
boxSize="24px"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<RightFirstIcon transform={'rotate(-180deg)'} />
|
||||
</Button>
|
||||
<Button
|
||||
{...switchStyle}
|
||||
isDisabled={currentPage === 1}
|
||||
bg={currentPage !== 1 ? 'grayModern.250' : 'grayModern.150'}
|
||||
p="0"
|
||||
minW="0"
|
||||
boxSize="24px"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(currentPage - 1);
|
||||
}}
|
||||
>
|
||||
<ToLeftIcon />
|
||||
</Button>
|
||||
<Text color={'grayModern.500'}>{currentPage}</Text>
|
||||
<Text color={'grayModern.500'}>/</Text>
|
||||
<Text color={'grayModern.900'}>{totalPage}</Text>
|
||||
<Button
|
||||
{...switchStyle}
|
||||
isDisabled={isPreviousData || currentPage >= totalPage}
|
||||
bg={currentPage !== totalPage ? 'grayModern.250' : 'grayModern.150'}
|
||||
boxSize="24px"
|
||||
p="0"
|
||||
minW="0"
|
||||
borderRadius={'50%'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(currentPage + 1);
|
||||
}}
|
||||
>
|
||||
<ToLeftIcon transform={'rotate(180deg)'} />
|
||||
</Button>
|
||||
<Button
|
||||
{...switchStyle}
|
||||
isDisabled={isPreviousData || currentPage >= totalPage}
|
||||
bg={currentPage !== totalPage ? 'grayModern.250' : 'grayModern.150'}
|
||||
boxSize="24px"
|
||||
p="0"
|
||||
minW="0"
|
||||
borderRadius={'50%'}
|
||||
mr={'10px'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(totalPage);
|
||||
}}
|
||||
>
|
||||
<RightFirstIcon />
|
||||
</Button>
|
||||
</Flex>
|
||||
<Text fontSize="12px" fontWeight="500" color={'grayModern.900'}>
|
||||
{pageSize}
|
||||
</Text>
|
||||
<Text fontSize="12px" fontWeight="500" color={'grayModern.500'}>
|
||||
/{t('Page')}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Column, Row, Table as ReactTable, flexRender } from '@tanstack/react-table';
|
||||
import { CSSProperties } from 'react';
|
||||
import { Loading } from '@sealos/shadcn-ui/loading';
|
||||
import { cn } from '@sealos/shadcn-ui';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell
|
||||
} from '@sealos/shadcn-ui/table';
|
||||
|
||||
const getCommonPinningStyles = <T,>(column: Column<T, unknown>): CSSProperties => {
|
||||
const isPinned = column.getIsPinned();
|
||||
|
||||
return {
|
||||
position: isPinned ? 'sticky' : 'relative',
|
||||
left: isPinned === 'left' ? 0 : undefined,
|
||||
right: isPinned === 'right' ? 0 : undefined,
|
||||
zIndex: isPinned ? 10 : 0
|
||||
};
|
||||
};
|
||||
|
||||
export function BaseTable<T extends unknown>({
|
||||
table,
|
||||
isLoading,
|
||||
isHeaderFixed = false,
|
||||
className,
|
||||
rowClassName,
|
||||
...props
|
||||
}: {
|
||||
table: ReactTable<T>;
|
||||
isLoading: boolean;
|
||||
isHeaderFixed?: boolean;
|
||||
className?: string;
|
||||
rowClassName?: (row: Row<T>, rowIndex: number) => string;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('relative w-full h-full overflow-auto scrollbar-default', className)}
|
||||
{...props}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className={cn('z-10', isHeaderFixed && 'sticky top-0')}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="bg-zinc-50 hover:bg-zinc-50">
|
||||
{headerGroup.headers.map((header, i) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
'h-10 text-sm font-normal text-zinc-500 px-4 py-2 border-none',
|
||||
i === 0 && 'rounded-l-lg',
|
||||
i === headerGroup.headers.length - 1 && 'rounded-r-lg'
|
||||
)}
|
||||
style={getCommonPinningStyles(header.column)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={table.getAllColumns().length}
|
||||
className="relative h-[300px] text-center py-4"
|
||||
>
|
||||
<Loading fixed={false} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row, rowIndex) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn('text-sm !border-b border-zinc-100', rowClassName?.(row, rowIndex))}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="px-4 py-2 text-zinc-900"
|
||||
style={{
|
||||
...getCommonPinningStyles(cell.column),
|
||||
backgroundColor: isPinned ? 'inherit' : undefined
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Flex, Box } from '@chakra-ui/react';
|
||||
|
||||
const ButtonGroup = ({
|
||||
list
|
||||
}: {
|
||||
list: {
|
||||
label: string;
|
||||
onclick: () => void;
|
||||
active: boolean;
|
||||
}[];
|
||||
}) => {
|
||||
return (
|
||||
<Flex borderRadius={'sm'} overflow={'hidden'} fontSize={'sm'}>
|
||||
{list.map((item) => (
|
||||
<Box
|
||||
key={item.label}
|
||||
py={1}
|
||||
px={6}
|
||||
backgroundColor={item.active ? 'blue.500' : 'blackAlpha.50'}
|
||||
color={item.active ? 'white' : 'blackAlpha.700'}
|
||||
cursor={'pointer'}
|
||||
onClick={item.onclick}
|
||||
_first={{
|
||||
borderTopLeftRadius: 'sm',
|
||||
borderBottomLeftRadius: 'sm'
|
||||
}}
|
||||
_last={{
|
||||
borderTopRightRadius: 'sm',
|
||||
borderBottomRightRadius: 'sm'
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonGroup;
|
||||
@@ -1,568 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { format, isAfter, isBefore, subDays } from 'date-fns';
|
||||
import { enUS, zhCN } from 'date-fns/locale';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import { DateRange, DayPicker } from 'react-day-picker';
|
||||
import useDateTimeStore from '@/store/date';
|
||||
import {
|
||||
formatDateInTimeZone,
|
||||
formatDateTimeInTimeZone,
|
||||
formatTimeInTimeZone,
|
||||
getBrowserTimeZone,
|
||||
getBoundedRangeStart,
|
||||
getBoundedDayRangeInTimeZone,
|
||||
getDayBoundsInTimeZone,
|
||||
normalizeTimeInput,
|
||||
orderDateRange,
|
||||
parseTimeRange,
|
||||
parseDateTimeInTimeZone
|
||||
} from '@/utils/timeRange';
|
||||
import { Button } from '@sealos/shadcn-ui/button';
|
||||
import { Calendar, RefreshCw } from 'lucide-react';
|
||||
import { Input } from '@sealos/shadcn-ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@sealos/shadcn-ui/popover';
|
||||
import { cn } from '@sealos/shadcn-ui';
|
||||
import 'react-day-picker/style.css';
|
||||
|
||||
interface DatePickerProps {
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface RecentDate {
|
||||
label: string;
|
||||
value: DateRange;
|
||||
compareValue: string;
|
||||
}
|
||||
|
||||
const DatePicker = ({ isDisabled = false, className }: DatePickerProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentLang = i18n.language;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const timeZone = useMemo(() => getBrowserTimeZone(), []);
|
||||
|
||||
const {
|
||||
startDateTime,
|
||||
endDateTime,
|
||||
setStartDateTime,
|
||||
setEndDateTime,
|
||||
setManualRange,
|
||||
setAutoRange
|
||||
} = useDateTimeStore();
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = subDays(now, 7);
|
||||
const earliestCalendarDay = getDayBoundsInTimeZone(sevenDaysAgo, timeZone).start;
|
||||
const latestCalendarDay = getDayBoundsInTimeZone(now, timeZone).start;
|
||||
|
||||
const initState = {
|
||||
from: startDateTime,
|
||||
to: endDateTime
|
||||
};
|
||||
|
||||
const recentDateList = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: `${t('recently')} 5 ${t('minute')}`,
|
||||
value: getDateRange('5m'),
|
||||
compareValue: '5m'
|
||||
},
|
||||
{
|
||||
label: `${t('recently')} 15 ${t('minute')}`,
|
||||
value: getDateRange('15m'),
|
||||
compareValue: '15m'
|
||||
},
|
||||
{
|
||||
label: `${t('recently')} 30 ${t('minute')}`,
|
||||
value: getDateRange('30m'),
|
||||
compareValue: '30m'
|
||||
},
|
||||
{
|
||||
label: `${t('recently')} 1 ${t('hour-singular')}`,
|
||||
value: getDateRange('1h'),
|
||||
compareValue: '1h'
|
||||
},
|
||||
{
|
||||
label: `${t('recently')} 3 ${t('hour')}`,
|
||||
value: getDateRange('3h'),
|
||||
compareValue: '3h'
|
||||
},
|
||||
{
|
||||
label: `${t('recently')} 6 ${t('hour')}`,
|
||||
value: getDateRange('6h'),
|
||||
compareValue: '6h'
|
||||
},
|
||||
{
|
||||
label: `${t('recently')} 24 ${t('hour')}`,
|
||||
value: getDateRange('24h'),
|
||||
compareValue: '24h'
|
||||
},
|
||||
{
|
||||
label: `${t('recently')} 2 ${t('day')}`,
|
||||
value: getDateRange('2d'),
|
||||
compareValue: '2d'
|
||||
},
|
||||
{
|
||||
label: `${t('recently')} 3 ${t('day')}`,
|
||||
value: getDateRange('3d'),
|
||||
compareValue: '3d'
|
||||
},
|
||||
{
|
||||
label: `${t('recently')} 7 ${t('day')}`,
|
||||
value: getDateRange('7d'),
|
||||
compareValue: '7d'
|
||||
}
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const isExactMatch = (currentStart: Date, currentEnd: Date, presetRange: DateRange) => {
|
||||
if (!presetRange.from || !presetRange.to) return false;
|
||||
|
||||
const tolerance = 1000; // 1秒
|
||||
const startDiff = Math.abs(currentStart.getTime() - presetRange.from.getTime());
|
||||
const endDiff = Math.abs(currentEnd.getTime() - presetRange.to.getTime());
|
||||
|
||||
return startDiff <= tolerance && endDiff <= tolerance;
|
||||
};
|
||||
|
||||
const defaultRecentDate = useMemo(() => {
|
||||
const exactMatch = recentDateList.find((item) =>
|
||||
isExactMatch(startDateTime, endDateTime, item.value)
|
||||
);
|
||||
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [startDateTime, endDateTime, recentDateList]);
|
||||
|
||||
const [inputState, setInputState] = useState<0 | 1>(0);
|
||||
const [recentDate, setRecentDate] = useState<RecentDate | null>(defaultRecentDate);
|
||||
|
||||
const [fromDateString, setFromDateString] = useState<string>(
|
||||
formatDateInTimeZone(initState.from, timeZone)
|
||||
);
|
||||
const [toDateString, setToDateString] = useState<string>(
|
||||
formatDateInTimeZone(initState.to, timeZone)
|
||||
);
|
||||
const [fromTimeString, setFromTimeString] = useState<string>(
|
||||
formatTimeInTimeZone(initState.from, timeZone)
|
||||
);
|
||||
const [toTimeString, setToTimeString] = useState<string>(
|
||||
formatTimeInTimeZone(initState.to, timeZone)
|
||||
);
|
||||
|
||||
const [fromDateError, setFromDateError] = useState<string | null>(null);
|
||||
const [toDateError, setToDateError] = useState<string | null>(null);
|
||||
const [fromTimeError, setFromTimeError] = useState<string | null>(null);
|
||||
const [toTimeError, setToTimeError] = useState<string | null>(null);
|
||||
const [fromDateShake, setFromDateShake] = useState(false);
|
||||
const [toDateShake, setToDateShake] = useState(false);
|
||||
const [fromTimeShake, setFromTimeShake] = useState(false);
|
||||
const [toTimeShake, setToTimeShake] = useState(false);
|
||||
|
||||
const [selectedRange, setSelectedRange] = useState<DateRange | undefined>(initState);
|
||||
|
||||
const syncRange = ({ from, to }: { from: Date; to: Date }) => {
|
||||
setSelectedRange({ from, to });
|
||||
setFromDateString(formatDateInTimeZone(from, timeZone));
|
||||
setFromTimeString(formatTimeInTimeZone(from, timeZone));
|
||||
setToDateString(formatDateInTimeZone(to, timeZone));
|
||||
setToTimeString(formatTimeInTimeZone(to, timeZone));
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (fromDateError || fromTimeError || toDateError || toTimeError) {
|
||||
if (fromDateError) setFromDateShake(true);
|
||||
if (toDateError) setToDateShake(true);
|
||||
if (fromTimeError) setFromTimeShake(true);
|
||||
if (toTimeError) setToTimeShake(true);
|
||||
setTimeout(() => {
|
||||
setFromDateShake(false);
|
||||
setToDateShake(false);
|
||||
setFromTimeShake(false);
|
||||
setToTimeShake(false);
|
||||
}, 300);
|
||||
|
||||
return;
|
||||
}
|
||||
const start = parseDateTimeInTimeZone(fromDateString, fromTimeString, timeZone);
|
||||
const end = parseDateTimeInTimeZone(toDateString, toTimeString, timeZone);
|
||||
|
||||
if (!start || !end) {
|
||||
if (!start) setFromTimeError('Invalid start time range');
|
||||
if (!end) setToTimeError('Invalid end time range');
|
||||
return;
|
||||
}
|
||||
|
||||
if (start > end) {
|
||||
setFromTimeError('Start time must be before end time');
|
||||
setToTimeError('End time must be after start time');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitNow = new Date();
|
||||
const earliestStart = subDays(submitNow, 7);
|
||||
const boundedStart = getBoundedRangeStart(start, end, earliestStart, timeZone);
|
||||
|
||||
if (!boundedStart) {
|
||||
setFromTimeError('start time cannot be before 7 days ago');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAfter(end, submitNow)) {
|
||||
setToTimeError('end time cannot be after current time');
|
||||
return;
|
||||
}
|
||||
|
||||
setFromDateError(null);
|
||||
setFromTimeError(null);
|
||||
setToDateError(null);
|
||||
setToTimeError(null);
|
||||
syncRange({ from: boundedStart, to: end });
|
||||
setStartDateTime(boundedStart);
|
||||
setEndDateTime(end);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleFromChange = (value: string, type: 'date' | 'time') => {
|
||||
setManualRange();
|
||||
const normalizedValue = type === 'time' ? normalizeTimeInput(value) : value;
|
||||
|
||||
if (type === 'date') {
|
||||
setFromDateString(value);
|
||||
} else {
|
||||
setFromTimeString(normalizedValue);
|
||||
}
|
||||
|
||||
const date = parseDateTimeInTimeZone(
|
||||
type === 'date' ? value : fromDateString,
|
||||
type === 'time' ? normalizedValue : fromTimeString,
|
||||
timeZone
|
||||
);
|
||||
|
||||
if (!date) {
|
||||
if (type === 'date') {
|
||||
setFromDateError('Invalid date format');
|
||||
} else {
|
||||
setFromTimeError('Invalid time format');
|
||||
}
|
||||
return setSelectedRange({ from: undefined, to: selectedRange?.to });
|
||||
}
|
||||
|
||||
if (isBefore(date, sevenDaysAgo)) {
|
||||
if (type === 'date') {
|
||||
setFromDateError('start time cannot be before 7 days ago');
|
||||
} else {
|
||||
setFromTimeError('start time cannot be before 7 days ago');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setFromDateError(null);
|
||||
setFromTimeError(null);
|
||||
|
||||
setRecentDate(null);
|
||||
|
||||
if (selectedRange?.to) {
|
||||
syncRange(orderDateRange(date, selectedRange.to));
|
||||
} else {
|
||||
syncRange({ from: date, to: date });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToChange = (value: string, type: 'date' | 'time') => {
|
||||
setManualRange();
|
||||
const normalizedValue = type === 'time' ? normalizeTimeInput(value) : value;
|
||||
|
||||
if (type === 'date') {
|
||||
setToDateString(value);
|
||||
} else {
|
||||
setToTimeString(normalizedValue);
|
||||
}
|
||||
|
||||
const date = parseDateTimeInTimeZone(
|
||||
type === 'date' ? value : toDateString,
|
||||
type === 'time' ? normalizedValue : toTimeString,
|
||||
timeZone
|
||||
);
|
||||
|
||||
if (!date) {
|
||||
if (type === 'date') {
|
||||
setToDateError('Invalid date format');
|
||||
} else {
|
||||
setToTimeError('Invalid time format');
|
||||
}
|
||||
return setSelectedRange({ from: selectedRange?.from, to: undefined });
|
||||
}
|
||||
|
||||
if (isAfter(date, now)) {
|
||||
if (type === 'date') {
|
||||
setToDateError('end time cannot be after current time');
|
||||
} else {
|
||||
setToTimeError('end time cannot be after current time');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setToDateError(null);
|
||||
setToTimeError(null);
|
||||
|
||||
setRecentDate(null);
|
||||
|
||||
if (selectedRange?.from) {
|
||||
syncRange(orderDateRange(selectedRange.from, date));
|
||||
} else {
|
||||
syncRange({ from: date, to: date });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRangeSelect = (range: DateRange | undefined) => {
|
||||
setManualRange();
|
||||
if (range) {
|
||||
let { from, to } = range;
|
||||
|
||||
if (inputState === 0) {
|
||||
// from
|
||||
if (from === selectedRange?.from) {
|
||||
// when 'to' is changed
|
||||
from = to;
|
||||
} else {
|
||||
to = from;
|
||||
}
|
||||
setInputState(1);
|
||||
} else {
|
||||
setInputState(0);
|
||||
}
|
||||
const boundedRange = getBoundedDayRangeInTimeZone(from, to, sevenDaysAgo, now, timeZone);
|
||||
|
||||
setSelectedRange(boundedRange);
|
||||
if (boundedRange.from) {
|
||||
setFromDateString(formatDateInTimeZone(boundedRange.from, timeZone));
|
||||
setFromTimeString(formatTimeInTimeZone(boundedRange.from, timeZone));
|
||||
} else {
|
||||
setFromDateString(formatDateInTimeZone(now, timeZone));
|
||||
setFromTimeString(formatTimeInTimeZone(now, timeZone));
|
||||
}
|
||||
if (boundedRange.to) {
|
||||
setToDateString(formatDateInTimeZone(boundedRange.to, timeZone));
|
||||
setToTimeString(formatTimeInTimeZone(boundedRange.to, timeZone));
|
||||
} else {
|
||||
const fallback = boundedRange.from || now;
|
||||
setToDateString(formatDateInTimeZone(fallback, timeZone));
|
||||
setToTimeString(formatTimeInTimeZone(fallback, timeZone));
|
||||
}
|
||||
|
||||
setRecentDate(null);
|
||||
} else {
|
||||
// default is cancel
|
||||
if (fromDateString && fromTimeString && selectedRange?.from) {
|
||||
setToDateString(fromDateString);
|
||||
setToTimeString(fromTimeString);
|
||||
setSelectedRange({
|
||||
...selectedRange,
|
||||
to: selectedRange.from
|
||||
});
|
||||
setInputState(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecentDateClick = (item: RecentDate) => {
|
||||
setAutoRange(item.compareValue);
|
||||
setFromDateError(null);
|
||||
setFromTimeError(null);
|
||||
setToDateError(null);
|
||||
setToTimeError(null);
|
||||
|
||||
const nextRange = getDateRange(item.compareValue);
|
||||
setRecentDate({ ...item, value: nextRange });
|
||||
setSelectedRange(nextRange);
|
||||
if (nextRange.from) {
|
||||
setFromDateString(formatDateInTimeZone(nextRange.from, timeZone));
|
||||
setFromTimeString(formatTimeInTimeZone(nextRange.from, timeZone));
|
||||
}
|
||||
if (nextRange.to) {
|
||||
setToDateString(formatDateInTimeZone(nextRange.to, timeZone));
|
||||
setToTimeString(formatTimeInTimeZone(nextRange.to, timeZone));
|
||||
}
|
||||
};
|
||||
|
||||
// format date time display
|
||||
const formatDateTimeDisplay = () => {
|
||||
return `${formatDateTimeInTimeZone(
|
||||
startDateTime,
|
||||
timeZone,
|
||||
'HH:mm, MMM DD'
|
||||
)} - ${formatDateTimeInTimeZone(endDateTime, timeZone, 'HH:mm, MMM DD')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-10 flex gap-2 items-center px-4 rounded-lg shadow-none hover:bg-zinc-50',
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Calendar className="w-4 h-4 text-neutral-500" />
|
||||
<span className="whitespace-nowrap text-gray-900 text-sm font-normal ">
|
||||
{formatDateTimeDisplay()}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-fit p-0 rounded-xl z-50 border-[0.5px] border-zinc-200"
|
||||
align="start"
|
||||
>
|
||||
<div className="w-[402px] flex">
|
||||
<div className="w-[242px] h-fit flex flex-col">
|
||||
<DayPicker
|
||||
mode="range"
|
||||
timeZone={timeZone}
|
||||
navLayout="around"
|
||||
selected={selectedRange}
|
||||
onSelect={handleRangeSelect}
|
||||
locale={currentLang === 'zh' ? zhCN : enUS}
|
||||
weekStartsOn={0}
|
||||
disabled={(date) => {
|
||||
const dayStart = getDayBoundsInTimeZone(date, timeZone).start;
|
||||
return (
|
||||
isAfter(dayStart, latestCalendarDay) || isBefore(dayStart, earliestCalendarDay)
|
||||
);
|
||||
}}
|
||||
formatters={{
|
||||
formatWeekdayName: (date) =>
|
||||
format(date, 'EEE', { locale: currentLang === 'zh' ? zhCN : enUS })
|
||||
}}
|
||||
className="px-4 pb-2 pt-4"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1 px-4 pt-3 border-t border-zinc-100">
|
||||
<span className="text-xs text-zinc-600 ml-1">{t('start')}</span>
|
||||
<div className="w-full flex justify-center gap-1">
|
||||
<DatePickerInput
|
||||
value={fromDateString}
|
||||
onChange={(e) => handleFromChange(e.target.value, 'date')}
|
||||
error={!!fromDateError}
|
||||
showError={fromDateShake}
|
||||
/>
|
||||
<DatePickerInput
|
||||
value={fromTimeString}
|
||||
onChange={(e) => handleFromChange(e.target.value, 'time')}
|
||||
error={!!fromTimeError}
|
||||
showError={fromTimeShake}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 px-4 pt-2 pb-4">
|
||||
<span className="text-xs text-zinc-600 ml-1">{t('end')}</span>
|
||||
<div className="w-full flex justify-center gap-1">
|
||||
<DatePickerInput
|
||||
value={toDateString}
|
||||
onChange={(e) => handleToChange(e.target.value, 'date')}
|
||||
error={!!toDateError}
|
||||
showError={toDateShake}
|
||||
/>
|
||||
<DatePickerInput
|
||||
value={toTimeString}
|
||||
onChange={(e) => handleToChange(e.target.value, 'time')}
|
||||
error={!!toTimeError}
|
||||
showError={toTimeShake}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 border-l border-zinc-100">
|
||||
<div className="flex flex-col gap-0.5 py-3 px-2 w-full">
|
||||
{recentDateList.map((item) => (
|
||||
<Button
|
||||
key={JSON.stringify(item.value)}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-8 px-2 text-gray-900 text-xs font-normal justify-start hover:bg-gray-50',
|
||||
recentDate &&
|
||||
recentDate.compareValue === item.compareValue &&
|
||||
'bg-blue-50 text-blue-600 hover:bg-blue-50'
|
||||
)}
|
||||
onClick={() => handleRecentDateClick(item)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center px-3 py-2 border-t border-zinc-100">
|
||||
<span className="text-xs text-zinc-500">{timeZone}</span>
|
||||
<div className="flex gap-2 px-2.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border rounded-lg shadow-none h-9 w-9"
|
||||
onClick={() => {
|
||||
if (defaultRecentDate) {
|
||||
setRecentDate(defaultRecentDate);
|
||||
handleRecentDateClick(defaultRecentDate);
|
||||
} else {
|
||||
const defaultOption =
|
||||
recentDateList.find((item) => item.compareValue === '30m') || recentDateList[0];
|
||||
setRecentDate(defaultOption);
|
||||
handleRecentDateClick(defaultOption);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 text-neutral-500" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-w-14 rounded-lg shadow-none"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button className="rounded-lg min-w-14 shadow-none" onClick={() => onSubmit()}>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
interface DatePickerInputProps {
|
||||
value: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement> | undefined;
|
||||
error: boolean;
|
||||
showError: boolean;
|
||||
}
|
||||
|
||||
const DatePickerInput = ({ value, onChange, error, showError }: DatePickerInputProps) => {
|
||||
return (
|
||||
<Input
|
||||
className={cn(
|
||||
'bg-white w-full h-8 !text-xs text-zinc-900 border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 shadow-none hover:bg-zinc-50',
|
||||
error && 'border-red-500 hover:border-red-500 focus:border-red-500 focus:ring-red-500',
|
||||
showError && 'border-red-500 hover:border-red-500 animate-shake'
|
||||
)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getDateRange = (value: string): DateRange => {
|
||||
const { startTime: from, endTime: to } = parseTimeRange(value);
|
||||
return { from, to };
|
||||
};
|
||||
|
||||
export default DatePicker;
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '@sealos/shadcn-ui/dialog';
|
||||
import { Button } from '@sealos/shadcn-ui/button';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ResponseCode } from '@/types/response';
|
||||
import { sealosApp } from 'sealos-desktop-sdk/app';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
|
||||
const ErrorModal = ({
|
||||
title,
|
||||
content,
|
||||
onClose,
|
||||
errorCode
|
||||
}: {
|
||||
title: string;
|
||||
content: string;
|
||||
onClose: () => void;
|
||||
errorCode?: ResponseCode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const openCostCenterApp = () => {
|
||||
sealosApp.runEvents('openDesktopApp', {
|
||||
appKey: 'system-costcenter',
|
||||
query: {
|
||||
openRecharge: 'true'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="w-[360px] text-foreground top-20 left-1/2 -translate-x-1/2 translate-y-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold leading-none">
|
||||
<TriangleAlert className="h-4 w-4 text-yellow-600" />
|
||||
{t(title)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[50vh] overflow-auto whitespace-pre-wrap text-sm">{t(content)}</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="lg" className="shadow-none" onClick={onClose}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="shadow-none"
|
||||
onClick={() => {
|
||||
if (errorCode === ResponseCode.BALANCE_NOT_ENOUGH) {
|
||||
openCostCenterApp();
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{errorCode === ResponseCode.BALANCE_NOT_ENOUGH ? t('add_credit') : t('Confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorModal;
|
||||
@@ -1,29 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { FormControl, FormErrorMessage } from '@chakra-ui/react';
|
||||
import type { FormControlProps } from '@chakra-ui/react';
|
||||
|
||||
interface Props extends FormControlProps {
|
||||
errorText?: string;
|
||||
showError?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MyFormControl = ({ errorText, showError = false, children, ...props }: Props) => {
|
||||
return (
|
||||
<FormControl
|
||||
position={'relative'}
|
||||
pb={showError ? '24px' : '0'}
|
||||
isInvalid={!!errorText}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showError && !!errorText && (
|
||||
<FormErrorMessage mt={1} position={'absolute'}>
|
||||
{errorText}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyFormControl;
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import MyIcon from '../Icon';
|
||||
import { GpuType } from '@/types/app';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
const GPUItem = ({ gpu }: { gpu?: GpuType }) => {
|
||||
const { t } = useTranslation();
|
||||
const { userSourcePrice } = useUserStore();
|
||||
|
||||
const gpuAlias = useMemo(() => {
|
||||
const gpuItem = userSourcePrice?.gpu?.find((item) => item.type === gpu?.type);
|
||||
|
||||
return gpuItem?.alias || gpu?.type || '';
|
||||
}, [gpu?.type, userSourcePrice?.gpu]);
|
||||
|
||||
return (
|
||||
<div className="flex whitespace-nowrap">
|
||||
<MyIcon name={'nvidia'} w={'16px'} className="mr-2" />
|
||||
{gpuAlias && (
|
||||
<>
|
||||
<span>{gpuAlias}</span>
|
||||
<span className="mx-1 text-zinc-500">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className={gpu?.amount ? 'text-zinc-600' : 'text-zinc-500'}>
|
||||
{gpuAlias ? gpu?.amount : 0}
|
||||
{t('Card')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(GPUItem);
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M2 3.33333L3.33333 2L14 12.6667L12.6667 14L2 3.33333ZM8.66667 3.33333L9.33333 2L10 3.33333L11.3333 4L10 4.66667L9.33333 6L8.66667 4.66667L7.33333 4L8.66667 3.33333ZM3.33333 10L4 8.66667L4.66667 10L6 10.6667L4.66667 11.3333L4 12.6667L3.33333 11.3333L2 10.6667L3.33333 10ZM2.66667 6L3.33333 6.66667L2.66667 7.33333L2 6.66667L2.66667 6ZM12 6.66667L12.6667 7.33333L12 8L11.3333 7.33333L12 6.66667Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 546 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M3 11V3H11V11H3ZM3 21V13H11V21H3ZM13 11V3H21V11H13ZM13 21V13H21V21H13ZM5 9H9V5H5V9ZM15 9H19V5H15V9ZM15 19H19V15H15V19ZM5 19H9V15H5V19Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 224 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0893 4.41083C13.4147 4.73626 13.4147 5.2639 13.0893 5.58934L8.67852 10.0001L13.0893 14.4108C13.4147 14.7363 13.4147 15.2639 13.0893 15.5893C12.7638 15.9148 12.2362 15.9148 11.9108 15.5893L6.91075 10.5893C6.58532 10.2639 6.58532 9.73626 6.91075 9.41083L11.9108 4.41083C12.2362 4.08539 12.7638 4.08539 13.0893 4.41083Z" fill="#485264"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 479 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.29289 5.29289C7.90237 5.68342 7.90237 6.31658 8.29289 6.70711L13.5858 12L8.29289 17.2929C7.90237 17.6834 7.90237 18.3166 8.29289 18.7071C8.68342 19.0976 9.31658 19.0976 9.70711 18.7071L15.7071 12.7071C16.0976 12.3166 16.0976 11.6834 15.7071 11.2929L9.70711 5.29289C9.31658 4.90237 8.68342 4.90237 8.29289 5.29289Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 491 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.60004 1.71324C4.60004 1.34505 4.89852 1.04657 5.26671 1.04657C5.6349 1.04657 5.93338 1.34505 5.93338 1.71324V2.72702H9.90671V1.71324C9.90671 1.34505 10.2052 1.04657 10.5734 1.04657C10.9416 1.04657 11.24 1.34505 11.24 1.71324V2.72826C12.262 2.73463 12.8418 2.77363 13.3012 3.01223C13.7639 3.25259 14.1411 3.62985 14.3815 4.09255C14.6667 4.64162 14.6667 5.36297 14.6667 6.80568V10.5881C14.6667 12.0308 14.6667 12.7521 14.3815 13.3012C14.1411 13.7639 13.7639 14.1412 13.3012 14.3815C12.7521 14.6667 12.0308 14.6667 10.588 14.6667H5.41204C3.96933 14.6667 3.24798 14.6667 2.69891 14.3815C2.23621 14.1412 1.85894 13.7639 1.61859 13.3012C1.33337 12.7521 1.33337 12.0308 1.33337 10.5881V6.80568C1.33337 5.36297 1.33337 4.64162 1.61859 4.09255C1.85894 3.62985 2.23621 3.25259 2.69891 3.01223C3.1337 2.78638 3.67653 2.73937 4.60004 2.72959V1.71324ZM9.90671 4.06035V4.66102C9.90671 5.02921 10.2052 5.32769 10.5734 5.32769C10.9416 5.32769 11.24 5.02921 11.24 4.66102V4.06168C11.6324 4.06418 11.9268 4.07128 12.1694 4.09143C12.5147 4.12011 12.634 4.16818 12.6865 4.19545C12.9057 4.3093 13.0844 4.48801 13.1983 4.70718C13.2255 4.75968 13.2736 4.87898 13.3023 5.22435C13.3323 5.58568 13.3334 6.06195 13.3334 6.80568V10.5881C13.3334 11.3318 13.3323 11.8081 13.3023 12.1694C13.2736 12.5148 13.2255 12.6341 13.1983 12.6866C13.0844 12.9057 12.9057 13.0844 12.6865 13.1983C12.634 13.2256 12.5147 13.2736 12.1694 13.3023C11.808 13.3323 11.3318 13.3334 10.588 13.3334H5.41204C4.66831 13.3334 4.19204 13.3323 3.83071 13.3023C3.48534 13.2736 3.36604 13.2256 3.31354 13.1983C3.09437 13.0844 2.91566 12.9057 2.80181 12.6866C2.77454 12.6341 2.72647 12.5148 2.69779 12.1694C2.66778 11.8081 2.66671 11.3318 2.66671 10.5881V6.80568C2.66671 6.06195 2.66778 5.58568 2.69779 5.22435C2.72647 4.87898 2.77454 4.75968 2.80181 4.70718C2.91566 4.48801 3.09437 4.3093 3.31354 4.19545C3.36604 4.16818 3.48534 4.12011 3.83071 4.09143C4.03914 4.07412 4.28583 4.06644 4.60004 4.06304V4.66102C4.60004 5.02921 4.89852 5.32769 5.26671 5.32769C5.6349 5.32769 5.93338 5.02921 5.93338 4.66102V4.06035H9.90671ZM11.6674 7.33333C12.0356 7.33333 12.3341 7.63181 12.3341 8C12.3341 8.36819 12.0356 8.66667 11.6674 8.66667H9.12117C8.75297 8.66667 8.4545 8.36819 8.4545 8C8.4545 7.63181 8.75298 7.33333 9.12117 7.33333H11.6674ZM12.3341 10.7482C12.3341 10.38 12.0356 10.0815 11.6674 10.0815H5.30444C4.93625 10.0815 4.63778 10.38 4.63778 10.7482C4.63778 11.1164 4.93625 11.4148 5.30444 11.4148H11.6674C12.0356 11.4148 12.3341 11.1164 12.3341 10.7482Z" fill="#667085"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 21" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.55791 8.35863C4.55791 7.89839 4.931 7.52529 5.39124 7.52529L17.5579 7.52529C18.0181 7.52529 18.3912 7.89839 18.3912 8.35863C18.3912 8.81886 18.0181 9.19196 17.5579 9.19196L5.39124 9.19196C4.931 9.19196 4.55791 8.81887 4.55791 8.35863Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.6688 4.46954C13.9942 4.1441 14.5219 4.1441 14.8473 4.46954L18.1472 7.76937C18.4726 8.09481 18.4726 8.62245 18.1472 8.94788C17.8217 9.27332 17.2941 9.27332 16.9686 8.94788L13.6688 5.64805C13.3434 5.32262 13.3434 4.79498 13.6688 4.46954Z" fill="currentColor"/>
|
||||
<path d="M2.23928 11.8554C2.14086 11.8961 2.04865 11.9564 1.96866 12.0364C1.88866 12.1164 1.82833 12.2086 1.78767 12.3071C1.74701 12.4052 1.72458 12.5128 1.72458 12.6257C1.72458 12.7387 1.74707 12.8464 1.78781 12.9447C1.82725 13.04 1.88513 13.1295 1.96146 13.2077C1.96413 13.2104 1.96682 13.2131 1.96953 13.2158L5.26849 16.5148C5.59392 16.8402 6.12156 16.8402 6.447 16.5148C6.77244 16.1893 6.77244 15.6617 6.447 15.3363L4.56976 13.459L14.7246 13.459C15.1848 13.459 15.5579 13.0859 15.5579 12.6257C15.5579 12.1655 15.1848 11.7924 14.7246 11.7924H2.55791C2.55535 11.7924 2.55278 11.7924 2.55022 11.7924C2.44016 11.7934 2.33521 11.8157 2.23928 11.8554Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 10" fill="currentColor">
|
||||
<line y1="5" x2="16" y2="5" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="4.00879" y="1" width="8" height="8" rx="4" fill="white" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 269 B |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<g clip-path="url(#clip0_623_2152)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.1453 3.2781L19.0532 6.00879C19.7719 6.68371 20.1313 7.02116 20.3871 7.42234C20.6045 7.76328 20.7661 8.13673 20.8658 8.52861C20.9831 8.98973 20.9831 9.48269 20.9831 10.4686V14.6201C20.2553 14.2695 19.4393 14.073 18.5773 14.073C15.5121 14.073 13.0273 16.5578 13.0273 19.6229C13.0273 20.6264 13.2937 21.5678 13.7596 22.3801H9.1349C6.97084 22.3801 5.88881 22.3801 5.0652 21.9522C4.37116 21.5917 3.80526 21.0258 3.44474 20.3318C3.01691 19.5082 3.01691 18.4261 3.01691 16.2621V7.73793C3.01691 5.57387 3.01691 4.49184 3.44474 3.66823C3.80526 2.97419 4.37116 2.40829 5.0652 2.04776C5.88881 1.61993 6.97084 1.61993 9.13491 1.61993H11.9573C12.8617 1.61993 13.3139 1.61993 13.7412 1.71997C14.1045 1.80503 14.4533 1.94313 14.7764 2.12983C15.1564 2.34943 15.486 2.65899 16.1453 3.2781ZM14.3329 4.65261C13.7396 4.08381 13.443 3.7994 13.1884 3.7891C12.9862 3.78091 12.7911 3.86416 12.6571 4.01578C12.4883 4.20668 12.4883 4.61761 12.4883 5.43949V6.37785C12.4883 7.58007 12.4883 8.18118 12.7223 8.64036C12.9281 9.04427 13.2565 9.37266 13.6604 9.57847C14.1196 9.81243 14.7207 9.81243 15.9229 9.81243H17.0023C17.8675 9.81243 18.3001 9.81243 18.4925 9.63783C18.6451 9.4993 18.7259 9.29844 18.7116 9.09279C18.6935 8.8336 18.3813 8.53421 17.7568 7.93543L14.3329 4.65261Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6293 16.208C17.9176 15.0877 19.5088 15.0877 19.7971 16.208C19.9533 16.8151 20.5776 17.1755 21.1814 17.0073C22.2958 16.6968 23.0914 18.0748 22.2653 18.8847C21.8177 19.3235 21.8177 20.0443 22.2653 20.4831C23.0914 21.293 22.2958 22.671 21.1814 22.3605C20.5776 22.1923 19.9533 22.5527 19.7971 23.1597C19.5088 24.2801 17.9176 24.2801 17.6293 23.1597C17.4731 22.5527 16.8488 22.1923 16.245 22.3605C15.1305 22.671 14.3349 21.293 15.1611 20.4831C15.6087 20.0443 15.6087 19.3235 15.1611 18.8847C14.3349 18.0748 15.1305 16.6968 16.245 17.0073C16.8488 17.1755 17.4731 16.8151 17.6293 16.208ZM20.1095 19.6839C20.1095 20.455 19.4843 21.0802 18.7132 21.0802C17.942 21.0802 17.3169 20.455 17.3169 19.6839C17.3169 18.9127 17.942 18.2876 18.7132 18.2876C19.4843 18.2876 20.1095 18.9127 20.1095 19.6839Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_623_2152">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<g clip-path="url(#clip0_623_2152)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.1453 3.2781L19.0532 6.00879C19.7719 6.68371 20.1313 7.02116 20.3871 7.42234C20.6045 7.76328 20.7661 8.13673 20.8658 8.52861C20.9831 8.98973 20.9831 9.48269 20.9831 10.4686V14.6201C20.2553 14.2695 19.4393 14.073 18.5773 14.073C15.5121 14.073 13.0273 16.5578 13.0273 19.6229C13.0273 20.6264 13.2937 21.5678 13.7596 22.3801H9.1349C6.97084 22.3801 5.88881 22.3801 5.0652 21.9522C4.37116 21.5917 3.80526 21.0258 3.44474 20.3318C3.01691 19.5082 3.01691 18.4261 3.01691 16.2621V7.73793C3.01691 5.57387 3.01691 4.49184 3.44474 3.66823C3.80526 2.97419 4.37116 2.40829 5.0652 2.04776C5.88881 1.61993 6.97084 1.61993 9.13491 1.61993H11.9573C12.8617 1.61993 13.3139 1.61993 13.7412 1.71997C14.1045 1.80503 14.4533 1.94313 14.7764 2.12983C15.1564 2.34943 15.486 2.65899 16.1453 3.2781ZM14.3329 4.65261C13.7396 4.08381 13.443 3.7994 13.1884 3.7891C12.9862 3.78091 12.7911 3.86416 12.6571 4.01578C12.4883 4.20668 12.4883 4.61761 12.4883 5.43949V6.37785C12.4883 7.58007 12.4883 8.18118 12.7223 8.64036C12.9281 9.04427 13.2565 9.37266 13.6604 9.57847C14.1196 9.81243 14.7207 9.81243 15.9229 9.81243H17.0023C17.8675 9.81243 18.3001 9.81243 18.4925 9.63783C18.6451 9.4993 18.7259 9.29844 18.7116 9.09279C18.6935 8.8336 18.3813 8.53421 17.7568 7.93543L14.3329 4.65261Z" fill="#667085"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6293 16.208C17.9176 15.0877 19.5088 15.0877 19.7971 16.208C19.9533 16.8151 20.5776 17.1755 21.1814 17.0073C22.2958 16.6968 23.0914 18.0748 22.2653 18.8847C21.8177 19.3235 21.8177 20.0443 22.2653 20.4831C23.0914 21.293 22.2958 22.671 21.1814 22.3605C20.5776 22.1923 19.9533 22.5527 19.7971 23.1597C19.5088 24.2801 17.9176 24.2801 17.6293 23.1597C17.4731 22.5527 16.8488 22.1923 16.245 22.3605C15.1305 22.671 14.3349 21.293 15.1611 20.4831C15.6087 20.0443 15.6087 19.3235 15.1611 18.8847C14.3349 18.0748 15.1305 16.6968 16.245 17.0073C16.8488 17.1755 17.4731 16.8151 17.6293 16.208ZM20.1095 19.6839C20.1095 20.455 19.4843 21.0802 18.7132 21.0802C17.942 21.0802 17.3169 20.455 17.3169 19.6839C17.3169 18.9127 17.942 18.2876 18.7132 18.2876C19.4843 18.2876 20.1095 18.9127 20.1095 19.6839Z" fill="#667085"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_623_2152">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon/line/cube">
|
||||
<path id="Icon (Stroke)" fill-rule="evenodd" clip-rule="evenodd" d="M7.73144 0.790031C7.90865 0.753584 8.09143 0.753584 8.26865 0.790031C8.47349 0.832161 8.65659 0.934713 8.80215 1.01624C8.8157 1.02383 8.82893 1.03124 8.84183 1.03841L13.7752 3.77915C13.7888 3.78672 13.8028 3.79446 13.8172 3.8024C13.9713 3.88727 14.1651 3.99398 14.3139 4.15543C14.4425 4.29502 14.5398 4.46046 14.5994 4.6407C14.6683 4.84917 14.6675 5.07035 14.6668 5.24627C14.6668 5.26271 14.6667 5.27875 14.6667 5.29435V10.7058C14.6667 10.7214 14.6668 10.7374 14.6668 10.7538C14.6675 10.9298 14.6683 11.1509 14.5994 11.3594C14.5398 11.5396 14.4425 11.7051 14.3139 11.8447C14.1651 12.0061 13.9713 12.1128 13.8173 12.1977C13.8029 12.2056 13.7888 12.2134 13.7752 12.221L8.84182 14.9617C8.82893 14.9689 8.8157 14.9763 8.80214 14.9839C8.65658 15.0654 8.47349 15.168 8.26865 15.2101C8.09143 15.2465 7.90865 15.2465 7.73144 15.2101C7.52659 15.1679 7.3435 15.0654 7.19793 14.9839C7.18438 14.9763 7.17115 14.9689 7.15826 14.9617L2.22493 12.221C2.21129 12.2134 2.19724 12.2056 2.18284 12.1977C2.02875 12.1128 1.83501 12.0061 1.68622 11.8447C1.55759 11.7051 1.46024 11.5397 1.40069 11.3594C1.33181 11.1509 1.33262 10.9298 1.33326 10.7538C1.33332 10.7374 1.33338 10.7214 1.33338 10.7058V5.29435C1.33338 5.27875 1.33332 5.26271 1.33326 5.24627C1.33262 5.07035 1.33181 4.84917 1.40069 4.6407C1.46024 4.46046 1.55759 4.29502 1.68622 4.15543C1.835 3.99398 2.02875 3.88727 2.18283 3.8024C2.19724 3.79447 2.21129 3.78673 2.22493 3.77915L7.15826 1.03841C7.17115 1.03124 7.18438 1.02383 7.19794 1.01624C7.3435 0.934713 7.52659 0.832162 7.73144 0.790031ZM8.00004 2.09938C7.99903 2.09987 7.99796 2.10037 7.99686 2.10091C7.95838 2.11946 7.90681 2.14782 7.80579 2.20395L3.3728 4.66672L8.00002 7.2374L12.6273 4.66671L8.1943 2.20395C8.09327 2.14782 8.0417 2.11946 8.00323 2.10091C8.00212 2.10037 8.00106 2.09987 8.00004 2.09938ZM13.3334 5.7997V10.7058C13.3334 10.8277 13.3331 10.8902 13.3304 10.9354C13.3303 10.9367 13.3302 10.938 13.3301 10.9392C13.3291 10.9399 13.328 10.9406 13.3269 10.9413C13.2887 10.9656 13.2343 10.9962 13.1276 11.0554L8.66671 13.5337L8.66669 8.3923L13.3334 5.7997ZM7.33336 8.39231L2.66671 5.79973V10.7058C2.66671 10.8277 2.66699 10.8902 2.66971 10.9354C2.66979 10.9367 2.66987 10.938 2.66995 10.9392C2.67098 10.9399 2.67205 10.9406 2.67318 10.9413C2.71137 10.9656 2.76582 10.9962 2.87245 11.0554L7.33337 13.5337L7.33336 8.39231Z" fill="#667085"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.5847 13.9377L12.6633 11.0838C13.4324 10.6516 13.9087 10.382 14.2331 10.159C14.3395 10.0858 14.4057 10.033 14.4441 9.99996C14.4057 9.96691 14.3395 9.91415 14.2331 9.84093C13.9087 9.61789 13.4324 9.34833 12.6633 8.91612L7.5847 6.06222C6.81588 5.63018 6.33748 5.3632 5.97609 5.20099C5.82602 5.13362 5.73587 5.10359 5.69125 5.09067C5.66592 5.09843 5.64384 5.10962 5.62534 5.12262C5.61577 5.169 5.60259 5.24756 5.59112 5.36921C5.55534 5.74884 5.55358 6.27983 5.55358 7.14606L5.55357 12.8539C5.55357 13.7201 5.55534 14.2511 5.59112 14.6307C5.60259 14.7524 5.61577 14.8309 5.62535 14.8773C5.64384 14.8903 5.66592 14.9015 5.69125 14.9092C5.73586 14.8963 5.82602 14.8663 5.97609 14.7989C6.33748 14.6367 6.81588 14.3697 7.5847 13.9377ZM15.9505 9.25531C15.7019 8.71189 14.9612 8.29564 13.4798 7.46316L8.40119 4.60925C6.91977 3.77677 6.17906 3.36052 5.57125 3.4227C5.04109 3.47694 4.55947 3.74758 4.24614 4.16735C3.88691 4.6486 3.88691 5.48109 3.88691 7.14606L3.88691 12.8539C3.88691 14.5188 3.88691 15.3513 4.24614 15.8326C4.55947 16.2523 5.04109 16.523 5.57125 16.5772C6.17906 16.6394 6.91977 16.2231 8.4012 15.3907L13.4798 12.5368C14.9612 11.7043 15.7019 11.288 15.9505 10.7446C16.1673 10.2706 16.1673 9.72932 15.9505 9.25531Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.27413 2.63007C3.13074 2.63007 2.99322 2.68703 2.89183 2.78842C2.79044 2.88982 2.73348 3.02733 2.73348 3.17072V8.60365C2.73348 8.74704 2.79044 8.88456 2.89183 8.98595C2.99322 9.08734 3.13074 9.14431 3.27413 9.14431H3.87779C4.24598 9.14431 4.54446 9.44278 4.54446 9.81097C4.54446 10.1792 4.24598 10.4776 3.87779 10.4776H3.27413C2.77712 10.4776 2.30046 10.2802 1.94902 9.92876C1.59758 9.57732 1.40015 9.10067 1.40015 8.60365V3.17072C1.40015 2.67371 1.59758 2.19706 1.94902 1.84562C2.30046 1.49417 2.77712 1.29674 3.27413 1.29674H8.70706C9.20407 1.29674 9.68073 1.49417 10.0322 1.84562C10.3836 2.19706 10.581 2.67371 10.581 3.17072V3.77438C10.581 4.14257 10.2826 4.44105 9.91438 4.44105C9.54619 4.44105 9.24771 4.14257 9.24771 3.77438V3.17072C9.24771 3.02733 9.19075 2.88982 9.08936 2.78842C8.98797 2.68703 8.85045 2.63007 8.70706 2.63007H3.27413ZM7.49974 6.85569C7.20115 6.85569 6.95909 7.09774 6.95909 7.39634V12.8293C6.95909 13.1279 7.20115 13.3699 7.49974 13.3699H12.9327C13.2313 13.3699 13.4733 13.1279 13.4733 12.8293V7.39634C13.4733 7.09774 13.2313 6.85569 12.9327 6.85569H7.49974ZM5.62576 7.39634C5.62576 6.36136 6.46477 5.52235 7.49974 5.52235H12.9327C13.9676 5.52235 14.8067 6.36136 14.8067 7.39634V12.8293C14.8067 13.8642 13.9676 14.7033 12.9327 14.7033H7.49974C6.46477 14.7033 5.62576 13.8642 5.62576 12.8293V7.39634Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 21" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.3531 1.96753H10.6469C11.0507 1.96751 11.4027 1.9675 11.6935 1.99126C12.0015 2.01642 12.3134 2.07236 12.615 2.22602C13.0612 2.45339 13.424 2.8162 13.6514 3.26245C13.805 3.56403 13.861 3.87586 13.8861 4.1839C13.9062 4.42898 13.9093 4.71763 13.9098 5.04407H16.9222C17.3824 5.04407 17.7555 5.41717 17.7555 5.87741C17.7555 6.33764 17.3824 6.71074 16.9222 6.71074H16.2173V14.5257C16.2173 15.1429 16.2173 15.6562 16.1831 16.0751C16.1474 16.5114 16.0705 16.9182 15.875 17.302C15.5738 17.893 15.0934 18.3735 14.5024 18.6746C14.1186 18.8702 13.7118 18.9471 13.2755 18.9827C12.8566 19.0169 12.3433 19.0169 11.7261 19.0169H8.27386C7.65667 19.0169 7.14343 19.0169 6.72454 18.9827C6.28824 18.9471 5.88144 18.8702 5.49759 18.6746C4.90662 18.3735 4.42615 17.893 4.12504 17.302C3.92946 16.9182 3.85257 16.5114 3.81692 16.0751C3.78269 15.6562 3.7827 15.1429 3.78272 14.5257L3.78272 6.71074H3.07778C2.61754 6.71074 2.24445 6.33764 2.24445 5.87741C2.24445 5.41717 2.61754 5.04407 3.07778 5.04407H6.09021C6.09069 4.71763 6.09382 4.42898 6.11385 4.1839C6.13902 3.87586 6.19495 3.56403 6.34861 3.26245C6.57598 2.8162 6.93879 2.45339 7.38504 2.22602C7.68662 2.07236 7.99845 2.01642 8.30649 1.99126C8.59725 1.9675 8.94934 1.96751 9.3531 1.96753ZM5.44938 6.71074V14.4917C5.44938 15.1516 5.45003 15.5964 5.47805 15.9394C5.50528 16.2726 5.55413 16.4356 5.61005 16.5454C5.75137 16.8227 5.97688 17.0482 6.25424 17.1896C6.36397 17.2455 6.52703 17.2943 6.86026 17.3216C7.20322 17.3496 7.64801 17.3502 8.3079 17.3502H11.6921C12.352 17.3502 12.7968 17.3496 13.1397 17.3216C13.473 17.2943 13.636 17.2455 13.7458 17.1896C14.0231 17.0482 14.2486 16.8227 14.3899 16.5454C14.4459 16.4356 14.4947 16.2726 14.5219 15.9394C14.55 15.5964 14.5506 15.1516 14.5506 14.4917V6.71074H5.44938ZM12.2431 5.04407H7.75693C7.75753 4.72102 7.76049 4.49694 7.77498 4.31962C7.79173 4.11465 7.81963 4.04657 7.83362 4.0191C7.90121 3.88646 8.00905 3.77862 8.14169 3.71103C8.16916 3.69703 8.23724 3.66913 8.44221 3.65239C8.65692 3.63485 8.94018 3.6342 9.38469 3.6342H10.6153C11.0598 3.6342 11.3431 3.63485 11.5578 3.65239C11.7628 3.66913 11.8308 3.69703 11.8583 3.71103C11.9909 3.77862 12.0988 3.88646 12.1664 4.0191C12.1804 4.04657 12.2083 4.11465 12.225 4.31962C12.2395 4.49694 12.2425 4.72102 12.2431 5.04407ZM8.46173 9.27432C8.92196 9.27432 9.29506 9.64741 9.29506 10.1076V13.9533C9.29506 14.4136 8.92196 14.7867 8.46173 14.7867C8.00149 14.7867 7.62839 14.4136 7.62839 13.9533V10.1076C7.62839 9.64741 8.00149 9.27432 8.46173 9.27432ZM11.5383 9.27432C11.9985 9.27432 12.3716 9.64741 12.3716 10.1076V13.9533C12.3716 14.4136 11.9985 14.7867 11.5383 14.7867C11.078 14.7867 10.7049 14.4136 10.7049 13.9533V10.1076C10.7049 9.64741 11.078 9.27432 11.5383 9.27432Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M5 15.95L11 19.425V12.575L5 9.1V15.95ZM13 19.425L19 15.95V9.1L13 12.575V19.425ZM6.075 7.425L12 10.85L17.925 7.425L12 4L6.075 7.425ZM3 17.125V8.025C3 7.65833 3.0875 7.325 3.2625 7.025C3.4375 6.725 3.68333 6.48333 4 6.3L11 2.275C11.3167 2.09167 11.65 2 12 2C12.35 2 12.6833 2.09167 13 2.275L20 6.3C20.3167 6.48333 20.5625 6.725 20.7375 7.025C20.9125 7.325 21 7.65833 21 8.025V15.975C21 16.3417 20.9125 16.675 20.7375 16.975C20.5625 17.275 20.3167 17.5167 20 17.7L13 21.725C12.6833 21.9083 12.35 22 12 22C11.65 22 11.3167 21.9083 11 21.725L3 17.125Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 636 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.34872 4.56519C1.34872 4.15097 1.70567 3.81519 2.14598 3.81519H15.854C16.2943 3.81519 16.6513 4.15097 16.6513 4.56519C16.6513 4.9794 16.2943 5.31519 15.854 5.31519H2.14598C1.70567 5.31519 1.34872 4.9794 1.34872 4.56519ZM1.34872 8.95324C1.34872 8.53903 1.70567 8.20324 2.14598 8.20324H15.854C16.2943 8.20324 16.6513 8.53903 16.6513 8.95324C16.6513 9.36745 16.2943 9.70324 15.854 9.70324H2.14598C1.70567 9.70324 1.34872 9.36745 1.34872 8.95324ZM1.34872 13.4347C1.34872 13.0204 1.70567 12.6847 2.14598 12.6847H10.9278C11.3681 12.6847 11.725 13.0204 11.725 13.4347C11.725 13.8489 11.3681 14.1847 10.9278 14.1847H2.14598C1.70567 14.1847 1.34872 13.8489 1.34872 13.4347Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 820 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1791 10.1803C17.1791 12.2588 15.5274 13.9515 13.465 14.0176V14.0196H13.1634C12.7492 14.0196 12.4134 13.6838 12.4134 13.2696C12.4134 12.8554 12.7492 12.5196 13.1634 12.5196H13.3525C14.6386 12.5128 15.6791 11.468 15.6791 10.1803C15.6791 9.0852 14.9252 8.16246 13.9062 7.90962L13.045 7.69598L12.8175 6.83844C12.3693 5.14897 10.8279 3.90576 9 3.90576C7.1721 3.90576 5.63074 5.14897 5.18249 6.83844L4.95496 7.69598L4.09386 7.90962C3.07478 8.16246 2.32092 9.0852 2.32092 10.1803C2.32092 11.4723 3.36828 12.5196 4.66026 12.5196L4.66409 12.5196L4.87712 12.5196C5.29133 12.5196 5.62712 12.8554 5.62712 13.2696C5.62712 13.6838 5.29133 14.0196 4.87712 14.0196H4.66026H4.57712V14.0187C2.4951 13.9745 0.820923 12.2729 0.820923 10.1803C0.820923 8.37972 2.06039 6.86866 3.73265 6.45376C4.35099 4.12325 6.47493 2.40576 9 2.40576C11.5251 2.40576 13.649 4.12325 14.2674 6.45376C15.9396 6.86866 17.1791 8.37972 17.1791 10.1803ZM8.99999 8.21269C8.58578 8.21269 8.24999 8.54848 8.24999 8.96269V13.3239L7.83764 12.9115C7.59165 12.6655 7.19281 12.6655 6.94682 12.9115C6.70083 13.1575 6.70083 13.5563 6.94682 13.8023L8.44361 15.2991C8.46052 15.3178 8.47836 15.3357 8.49705 15.3526L8.55359 15.4091C8.80013 15.6556 9.19985 15.6556 9.44639 15.4091L9.50294 15.3526C9.52163 15.3356 9.53946 15.3178 9.55636 15.2991L11.0522 13.8033C11.2987 13.5568 11.2987 13.1571 11.0522 12.9105C10.8056 12.664 10.4059 12.664 10.1594 12.9105L9.74999 13.3199V8.96269C9.74999 8.54847 9.4142 8.21269 8.99999 8.21269Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 17" fill="none">
|
||||
<path d="M12.8678 3.87406L14.1877 5.19399L15.0944 4.28729C15.2617 4.12 15.3453 4.03634 15.3928 3.94764C15.4999 3.74752 15.4999 3.50713 15.3928 3.307C15.3453 3.21831 15.2617 3.13466 15.0944 2.96737C14.9271 2.80007 14.8434 2.71641 14.7547 2.66895C14.5546 2.56189 14.3142 2.56189 14.1141 2.66895C14.0254 2.71641 13.9418 2.80006 13.7745 2.96736L12.8678 3.87406Z" fill="currentColor"/>
|
||||
<path d="M7.85653 11.3695C7.81276 11.3723 7.75862 11.3723 7.68866 11.3723H7.12348C6.9688 11.3723 6.89146 11.3723 6.83259 11.3418C6.78299 11.316 6.74254 11.2755 6.71677 11.2259C6.68619 11.1671 6.68619 11.0897 6.68619 10.935V10.3645C6.68619 10.2098 6.68619 10.1325 6.71677 10.0736C6.72009 10.0672 6.72367 10.0609 6.72747 10.0548C6.76658 9.97524 6.84688 9.89494 6.9871 9.75472L12.0035 4.73832L13.3234 6.05825L8.30703 11.0747C8.14297 11.2387 8.06094 11.3207 7.96606 11.3508C7.93037 11.362 7.89351 11.3683 7.85653 11.3695Z" fill="currentColor"/>
|
||||
<path d="M6.44527 2.88248C5.82405 2.88247 5.31681 2.88246 4.90498 2.91666C4.4789 2.95205 4.09557 3.02746 3.73892 3.21273C3.20317 3.49103 2.76633 3.92787 2.48803 4.46362C2.30276 4.82027 2.22735 5.2036 2.19196 5.62969C2.15776 6.04152 2.15777 6.54875 2.15778 7.16998V11.3274C2.15777 11.9486 2.15776 12.4558 2.19196 12.8677C2.22735 13.2938 2.30276 13.6771 2.48803 14.0337C2.76633 14.5695 3.20317 15.0063 3.73892 15.2846C4.09557 15.4699 4.4789 15.5453 4.90499 15.5807C5.31683 15.6149 5.82407 15.6149 6.44532 15.6149H10.6026C11.2239 15.6149 11.7311 15.6149 12.143 15.5807C12.5691 15.5453 12.9524 15.4699 13.309 15.2846C13.8448 15.0063 14.2816 14.5695 14.5599 14.0337C14.7452 13.6771 14.8206 13.2938 14.856 12.8677C14.8902 12.4559 14.8902 11.9486 14.8902 11.3274V8.63547C14.8902 8.26728 14.5917 7.96881 14.2235 7.96881C13.8553 7.96881 13.5569 8.26728 13.5569 8.63547V11.2989C13.5569 11.9555 13.5563 12.4072 13.5272 12.7573C13.4988 13.0995 13.4466 13.2845 13.3767 13.4191C13.2249 13.7113 12.9866 13.9496 12.6944 14.1014C12.5598 14.1713 12.3748 14.2235 12.0326 14.2519C11.6825 14.281 11.2308 14.2816 10.5742 14.2816H6.47377C5.81716 14.2816 5.36547 14.281 5.01534 14.2519C4.67319 14.2235 4.48817 14.1713 4.35355 14.1014C4.06132 13.9496 3.82305 13.7113 3.67125 13.4191C3.60132 13.2845 3.54914 13.0995 3.52072 12.7573C3.49165 12.4072 3.49111 11.9555 3.49111 11.2989V7.19847C3.49111 6.54186 3.49165 6.09017 3.52072 5.74004C3.54914 5.39789 3.60132 5.21287 3.67125 5.07825C3.82305 4.78602 4.06132 4.54775 4.35355 4.39595C4.48817 4.32602 4.67319 4.27384 5.01533 4.24542C5.36546 4.21635 5.81714 4.21581 6.47375 4.21581H9.16498C9.53317 4.21581 9.83165 3.91733 9.83165 3.54914C9.83165 3.18095 9.53317 2.88248 9.16498 2.88248L6.44527 2.88248Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="279" height="81" viewBox="0 0 279 81" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36.8463 19.9578C17.375 19.9578 0 28.641 0 28.641V80.5H278V8.91518C278 8.91518 268.965 2.81172 254.677 0.835213C228.925 -2.72733 225.331 23.2809 202.708 23.2809C186.091 23.281 184.944 7.02985 162.787 5.54853C140.631 4.06721 130.636 41.3864 94.919 40.9657C68.1625 40.6506 51.8933 19.9578 36.8463 19.9578Z" fill="url(#paint0_linear_1518_3722)"/>
|
||||
<path d="M0.492432 28.6916C0.492432 28.6916 17.4023 20.0784 36.8716 20.0784C53.0753 20.0784 68.1846 40.7581 94.9383 41.0731C130.652 41.4935 140.645 4.19778 162.799 5.67816C184.953 7.15855 186.101 23.3994 202.716 23.3994C225.337 23.3994 228.931 -2.59249 254.68 0.967815C268.966 2.94308 278 9.0427 278 9.0427" stroke="#111824" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1518_3722" x1="139.115" y1="18.2193" x2="138.975" y2="80.4999" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F3F3F4"/>
|
||||
<stop offset="1" stop-color="#F3F3F4" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 17" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 3.14327C9.63181 3.14327 9.33333 2.84479 9.33333 2.4766C9.33333 2.10841 9.63181 1.80994 10 1.80994H14C14.3682 1.80994 14.6667 2.10841 14.6667 2.4766V6.4766C14.6667 6.84479 14.3682 7.14327 14 7.14327C13.6318 7.14327 13.3333 6.84479 13.3333 6.4766V4.08608L9.80473 7.61467C9.54438 7.87502 9.12227 7.87502 8.86192 7.61467C8.60157 7.35433 8.60157 6.93221 8.86192 6.67187L12.3905 3.14327H10ZM7.13807 9.33853C7.39842 9.59888 7.39842 10.021 7.13807 10.2813L3.60947 13.8099H6C6.36818 13.8099 6.66666 14.1084 6.66666 14.4766C6.66666 14.8448 6.36818 15.1433 6 15.1433H1.99999C1.82318 15.1433 1.65361 15.073 1.52859 14.948C1.40357 14.823 1.33333 14.6534 1.33333 14.4766L1.33333 10.4766C1.33333 10.1084 1.63181 9.80994 1.99999 9.80994C2.36818 9.80994 2.66666 10.1084 2.66666 10.4766L2.66666 12.8671L6.19526 9.33853C6.45561 9.07818 6.87772 9.07818 7.13807 9.33853Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1008 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon/line/export">
|
||||
<path id="Icon (Stroke)" fill-rule="evenodd" clip-rule="evenodd" d="M7.52851 1.52876C7.78886 1.26841 8.21097 1.26841 8.47132 1.52876L11.138 4.19543C11.3983 4.45577 11.3983 4.87788 11.138 5.13823C10.8776 5.39858 10.4555 5.39858 10.1952 5.13823L8.66659 3.60964V10.0002C8.66659 10.3684 8.36811 10.6668 7.99992 10.6668C7.63173 10.6668 7.33325 10.3684 7.33325 10.0002V3.60964L5.80466 5.13823C5.54431 5.39858 5.1222 5.39858 4.86185 5.13823C4.6015 4.87788 4.6015 4.45577 4.86185 4.19543L7.52851 1.52876ZM1.99992 7.3335C2.36811 7.3335 2.66659 7.63197 2.66659 8.00016V10.8002C2.66659 11.3712 2.6671 11.7594 2.69162 12.0595C2.7155 12.3517 2.75878 12.5012 2.81191 12.6055C2.93974 12.8564 3.14372 13.0603 3.3946 13.1882C3.49887 13.2413 3.64833 13.2846 3.94061 13.3085C4.24067 13.333 4.62887 13.3335 5.19992 13.3335H10.7999C11.371 13.3335 11.7592 13.333 12.0592 13.3085C12.3515 13.2846 12.501 13.2413 12.6052 13.1882C12.8561 13.0603 13.0601 12.8564 13.1879 12.6055C13.2411 12.5012 13.2843 12.3517 13.3082 12.0595C13.3327 11.7594 13.3333 11.3712 13.3333 10.8002V8.00016C13.3333 7.63197 13.6317 7.3335 13.9999 7.3335C14.3681 7.3335 14.6666 7.63197 14.6666 8.00016V10.8277C14.6666 11.3644 14.6666 11.8073 14.6371 12.168C14.6065 12.5428 14.5408 12.8872 14.3759 13.2108C14.1203 13.7126 13.7123 14.1205 13.2106 14.3762C12.887 14.541 12.5425 14.6068 12.1678 14.6374C11.807 14.6668 11.3641 14.6668 10.8275 14.6668H5.17237C4.63573 14.6668 4.19283 14.6668 3.83204 14.6374C3.4573 14.6068 3.11283 14.541 2.78928 14.3762C2.28751 14.1205 1.87956 13.7126 1.6239 13.2108C1.45904 12.8872 1.39333 12.5428 1.36271 12.168C1.33324 11.8073 1.33324 11.3643 1.33325 10.8277L1.33325 8.00016C1.33325 7.63197 1.63173 7.3335 1.99992 7.3335Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.90953 5.81587L7.87406 3.82768C7.8224 3.72848 7.71983 3.66626 7.60798 3.66626H3.03746C2.78894 3.66626 2.58746 3.86773 2.58746 4.11626V13.2835C2.58746 13.8634 3.05757 14.3335 3.63747 14.3335H14.3626C14.9425 14.3335 15.4126 13.8634 15.4126 13.2835V6.86587C15.4126 6.28597 14.9425 5.81587 14.3626 5.81587H8.90953ZM1.08746 4.11626C1.08746 3.0393 1.96051 2.16626 3.03746 2.16626H7.60798C8.27908 2.16626 8.89445 2.5396 9.20444 3.13481L9.81956 4.31587H14.3626C15.7709 4.31587 16.9126 5.45754 16.9126 6.86587V13.2835C16.9126 14.6919 15.7709 15.8335 14.3626 15.8335H3.63747C2.22914 15.8335 1.08746 14.6919 1.08746 13.2835V4.11626Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 779 B |