feat web terminal frontend (#2972)

* feat web terminal frontend

Signed-off-by: zhu jing yang <3161362058@qq.com>

* fix postmessage security

Signed-off-by: zhu jing yang <3161362058@qq.com>

* postMessage specifies the source

Signed-off-by: zhu jing yang <3161362058@qq.com>

* feat terminal & imagehub github action

Signed-off-by: zhu jing yang <3161362058@qq.com>

---------

Signed-off-by: zhu jing yang <3161362058@qq.com>
This commit is contained in:
zhujingyang
2023-04-24 17:29:22 +08:00
committed by GitHub
parent 8783311762
commit 147ed6d372
32 changed files with 6972 additions and 1 deletions
-1
View File
@@ -24,7 +24,6 @@ jobs:
with:
images: |
ghcr.io/${{ github.repository_owner }}/bytebase-frontend
# https://github.com/docker/metadata-action#typesemver
tags: |
type=raw,value=latest,enable=true
type=sha,enable=true,format=short
+51
View File
@@ -0,0 +1,51 @@
name: Imagehub-Frontend
on:
push:
branches: ['main']
paths:
- 'frontend/providers/imagehub/**'
- '.github/workflows/imagehub-frontend.yml'
- '!**/*.md'
jobs:
imagehub-frontend:
runs-on: ubuntu-20.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/${{ github.repository_owner }}/imagehub-frontend
tags: |
type=raw,value=latest,enable=true
type=sha,enable=true,format=short
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Github Container Hub
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GH_PAT }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: ./frontend/providers/imagehub
file: ./frontend/providers/imagehub/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+51
View File
@@ -0,0 +1,51 @@
name: Terminal-Frontend
on:
push:
branches: ['main']
paths:
- 'frontend/providers/terminal/**'
- '.github/workflows/terminal-frontend.yml'
- '!**/*.md'
jobs:
terminal-frontend:
runs-on: ubuntu-20.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/${{ github.repository_owner }}/terminal-frontend
tags: |
type=raw,value=latest,enable=true
type=sha,enable=true,format=short
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Github Container Hub
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GH_PAT }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: ./frontend/providers/terminal
file: ./frontend/providers/terminal/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_SITE="https://cloud.sealos.io/"
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+36
View File
@@ -0,0 +1,36 @@
# 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
+72
View File
@@ -0,0 +1,72 @@
# Copyright © 2022 sealos.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Install dependencies only when needed
FROM node:current-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat && npm install -g pnpm
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json pnpm-lock.yaml* ./
RUN \
[ -f pnpm-lock.yaml ] && pnpm install || \
(echo "Lockfile not found." && exit 1)
# Rebuild the source code only when needed
FROM node:current-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm install -g pnpm && pnpm run build
# Production image, copy all the files and run next
FROM node:current-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN sed -i 's/https/http/' /etc/apk/repositories
RUN apk add curl \
&& apk add ca-certificates \
&& update-ca-certificates
# You only need to copy next.config.js if you are NOT using the default configuration
# COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
+46
View File
@@ -0,0 +1,46 @@
SERVICE_NAME=zhujingyang/sealos-terminal
# Image URL to use all building/pushing image targets
IMG ?= $(SERVICE_NAME):1.05
.PHONY: all
all: docker-build-and-push
##@ 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
.PHONY: docker-build
docker-build: ## Build docker image with the desktop-frontend.
sudo docker build -t $(IMG) .
##@ Deployment
.PHONY: docker-push-image
docker-push-image: ## Push docker image to Docker Hub.
docker push $(IMG)
.PHONY: docker-build-and-push
docker-build-and-push: docker-build docker-push-image ## Build and push docker image with the desktop-frontend.
+5
View File
@@ -0,0 +1,5 @@
## sealos terminal
## env
Support the definition of SITE environment variables, the page is not authorized to jump to the SITE URL
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
output: 'standalone'
}
module.exports = nextConfig
+41
View File
@@ -0,0 +1,41 @@
{
"name": "terminal",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@chakra-ui/react": "^2.5.5",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@kubernetes/client-node": "0.18.0",
"@tanstack/react-query": "^4.28.0",
"axios": "1.2.1",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"framer-motion": "^10.11.6",
"immer": "^9.0.16",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"nanoid": "^4.0.2",
"next": "13.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"sealos-desktop-sdk": "^0.1.11",
"typescript": "5.0.2",
"zustand": "^4.1.5"
},
"devDependencies": {
"@types/lodash": "^4.14.191",
"@types/js-yaml": "^4.0.5",
"@types/node": "18.15.5",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"sass": "^1.57.1"
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
type TIconfont = {
iconName: string;
color?: string;
width?: number;
height?: number;
};
function Iconfont(props: TIconfont) {
const { iconName, color, width, height } = props;
const style = {
fill: color,
width,
height
};
return (
<svg className="icon" aria-hidden="true" style={style}>
<use xlinkHref={`#${iconName}`}></use>
</svg>
);
}
export default Iconfont;
@@ -0,0 +1,36 @@
.tabs {
.closeIcon {
display: none;
}
&:hover {
background: #232528;
.closeIcon {
display: block;
}
}
&[data-isactive='true'] {
.closeIcon {
display: block;
}
}
}
.iframeWindow {
width: 100%;
height: 100%;
}
.containerLeft {
width: 200px;
font-size: 12px;
font-weight: 400;
}
@media (max-width: 768px) {
.containerLeft {
width: 136px;
font-size: 12px;
}
}
@@ -0,0 +1,180 @@
import Iconfont from '@/components/iconfont'
import {
Box,
Drawer,
DrawerBody,
DrawerContent,
DrawerHeader,
DrawerOverlay,
Flex,
Text,
useDisclosure,
} from '@chakra-ui/react'
import { useEffect, useState } from 'react'
import styles from './index.module.scss'
import { nanoid } from 'nanoid'
import { debounce } from 'lodash'
type Terminal = {
id: string
command?: string
}
function Terminal({ url }: { url: string }) {
const { isOpen, onOpen, onClose } = useDisclosure()
const [tabId, setTabId] = useState(nanoid(6))
const [tabContents, setTabContents] = useState<Terminal[]>([
{
id: tabId,
},
])
useEffect(() => {
try {
window.addEventListener('message', (e) => {
if (
e.origin === process.env.NEXT_PUBLIC_SITE &&
e.data.type === 'new terminal' &&
e.data.command
) {
newTerminal(e.data.command)
}
})
} catch (error) {
console.log(error)
}
}, [])
const onLoadIframe = (e: any, item: Terminal) => {
try {
if (item.command) {
setTimeout(() => {
e.target.contentWindow.postMessage({ command: item.command }, url)
}, 2000)
}
} catch (error) {
console.log(error)
}
}
const newTerminal = (command?: string) => {
const temp = nanoid(6)
setTabContents((pre) => {
return [
...pre,
{
id: temp,
command: command,
},
]
})
setTabId(temp)
}
const deleteTerminal = (key: string) => {
if (tabContents.length <= 1) return
setTabContents((pre) => {
const temp = pre.filter((item) => item.id !== key)
setTabId(temp[temp.length - 1].id)
return temp
})
}
const onTabChange = (id: string) => {
setTabId(id)
}
return (
<Flex w="100%" h="100%" color="white" bg="#2b2b2b" overflow={'hidden'}>
<Flex
backgroundColor={'#2C3035'}
userSelect={'none'}
flexDirection={'column'}
cursor={'pointer'}
className={styles.containerLeft}>
<Flex
flexShrink={0}
h="50px"
pl="16px"
alignItems={'center'}
borderBottom={'2px solid #232528'}
onClick={debounce(() => newTerminal(), 500)}>
<Iconfont
color="rgba(255, 255, 255, 0.9)"
iconName="icon-a-material-symbols_addadd1"
width={16}
height={16}
/>
<Text color="rgba(255, 255, 255, 0.9)" pl={'8px'} isTruncated>
Add a Terminal
</Text>
</Flex>
<Box overflowX={'hidden'} overflowY="auto" pb="20px">
{tabContents?.map((item: Terminal, index: number) => {
return (
<Flex
py="12px"
pl="16px"
pr="12px"
bg={item?.id === tabId ? '#232528' : ''}
key={item?.id}
alignItems="center"
onClick={() => onTabChange(item?.id)}
className={styles.tabs}
data-isactive={item?.id === tabId}>
<Iconfont
iconName="icon-codicon_terminalterminal"
color="rgba(255, 255, 255, 0.9)"
width={14}
height={14}></Iconfont>
<Text isTruncated color="rgba(255, 255, 255, 0.9)" pl="8px">
{`terminal ${index + 1}`}
</Text>
<Box
ml="auto"
className={styles.closeIcon}
onClick={() => deleteTerminal(item?.id)}>
<Iconfont
iconName="icon-delete"
color="rgba(255, 255, 255, 0.9)"
width={14}
height={14}></Iconfont>
</Box>
</Flex>
)
})}
</Box>
</Flex>
<Drawer onClose={onClose} isOpen={isOpen}>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader borderBottomWidth="1px">Basic Drawer</DrawerHeader>
<DrawerBody>
<p>Some contents...</p>
<p>Some contents...</p>
<p>Some contents...</p>
</DrawerBody>
</DrawerContent>
</Drawer>
{tabContents?.map((item: Terminal) => {
return (
<Box
flexGrow={1}
key={item?.id}
display={item?.id === tabId ? 'block' : 'none'}>
<iframe
onLoad={(e) => onLoadIframe(e, item)}
className={styles.iframeWindow}
id={tabId}
src={url}
allow="camera;microphone;clipboard-write;"
/>
</Box>
)
})}
</Flex>
)
}
export default Terminal
@@ -0,0 +1,9 @@
export type ApiResp = {
code?: number
message?: string
data?: any
error?: any
}
export const isApiResp = (x: any): x is ApiResp =>
typeof x.code === 'number' && typeof x.message === 'string'
@@ -0,0 +1,24 @@
export type OAuthToken = {
readonly access_token: string
readonly token_type: string
readonly refresh_token: string
readonly expiry: string
}
export type UserInfo = {
readonly id: string
readonly name: string
readonly avatar: string
}
export type KubeConfig = string
export type Session = {
token?: OAuthToken
user: UserInfo
kubeconfig: KubeConfig
}
const sessionKey = 'session'
export { sessionKey }
@@ -0,0 +1,44 @@
import * as yaml from 'js-yaml'
export type TerminalStatus = {
availableReplicas: number
domain?: string
}
export type TerminalForm = {
user_name: string
token: string
namespace: string
currentTime: string
terminal_name: string
}
// this template is suite for golang(kubernetes and sealos)'s template engine
export const generateTerminalTemplate = (form: TerminalForm): string => {
const temp = {
apiVersion: 'terminal.sealos.io/v1',
kind: 'Terminal',
metadata: {
name: form.terminal_name,
namespace: form.namespace,
annotations: {
lastUpdateTime: form.currentTime,
},
},
spec: {
user: form.user_name,
token: form.token,
apiServer: 'https://kubernetes.default.svc.cluster.local:443',
ttyImage: 'hub.sealos.cn/labring/terminal-app:1.19.4',
replicas: 1,
keepalived: '4h',
},
}
try {
const result = yaml.dump(temp)
return result
} catch (error) {
return ''
}
}
@@ -0,0 +1,24 @@
import '@/styles/globals.scss'
import { ChakraProvider } from '@chakra-ui/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { AppProps } from 'next/app'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
cacheTime: 0,
},
},
})
export default function App({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<ChakraProvider>
<Component {...pageProps} />
</ChakraProvider>
</QueryClientProvider>
)
}
@@ -0,0 +1,15 @@
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head>
<script src="/iconfont/iconfont.js" async></script>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
@@ -0,0 +1,72 @@
import { generateTerminalTemplate, TerminalStatus } from '@/interfaces/terminal'
import { authSession } from '@/service/auth'
import {
ApplyYaml,
CRDMeta,
GetCRD,
GetUserDefaultNameSpace,
K8sApi,
} from '@/service/kubernetes'
import { jsonRes } from '@/service/response'
import type { NextApiRequest, NextApiResponse } from 'next'
export const terminal_meta: CRDMeta = {
group: 'terminal.sealos.io',
version: 'v1',
namespace: 'terminal-app',
plural: 'terminals',
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const kubeconfig = await authSession(req.headers)
const kc = K8sApi(kubeconfig)
const kube_user = kc.getCurrentUser()
if (!kube_user || !kube_user.token || !kube_user.name) {
throw new Error('kube_user get failed')
}
const terminal_name = 'terminal-' + kube_user.name
const namespace = GetUserDefaultNameSpace(kube_user.name)
// first get user namespace crd
let terminal_meta_user = { ...terminal_meta }
terminal_meta_user.namespace = namespace
try {
// get crd
const terminalUserDesc = await GetCRD(
kc,
terminal_meta_user,
terminal_name
)
if (terminalUserDesc?.body?.status) {
const terminalStatus = terminalUserDesc.body.status as TerminalStatus
if (terminalStatus.availableReplicas > 0) {
// temporarily add domain scheme
return jsonRes(res, { data: terminalStatus.domain || '' })
}
}
} catch (error) {
// console.log(error)
}
const terminal_yaml = generateTerminalTemplate({
namespace: namespace,
user_name: kube_user.name,
terminal_name: terminal_name,
token: kube_user.token,
currentTime: new Date().toISOString(),
})
const result = await ApplyYaml(kc, terminal_yaml)
jsonRes(res, { code: 201, data: result, message: '' })
} catch (error) {
// console.log(error)
jsonRes(res, { code: 500, error })
}
}
@@ -0,0 +1,29 @@
.container {
width: 100%;
height: 100%;
overflow: hidden;
}
.err {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.loading {
font-size: 20px;
animation: blink 2s infinite;
}
@keyframes blink {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@@ -0,0 +1,81 @@
import Terminal from '@/components/terminal'
import request from '@/service/request'
import useSessionStore from '@/stores/session'
import { useQuery } from '@tanstack/react-query'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app'
import styles from './index.module.scss'
export default function Index() {
const { setSession, isUserLogin } = useSessionStore()
const [url, setUrl] = useState('')
useEffect(() => {
return createSealosApp()
}, [])
useEffect(() => {
const initApp = async () => {
try {
const result = await sealosApp.getUserInfo()
setSession(result)
} catch (error) {}
}
initApp()
}, [setSession])
const { data, isLoading, isError, refetch } = useQuery(
['applyApp'],
() => request.post('/api/apply'),
{
onSuccess: (res) => {
if (res?.data?.code === 200 && res?.data?.data) {
const url = res?.data?.data
fetch(url, { mode: 'no-cors' })
.then(() => {
setUrl(url)
// window.location.replace(url)
})
.catch((err) => console.log(err))
}
if (res?.data?.code === 201) {
refetch()
}
},
onError: (err) => {
console.log(err, 'err')
},
retry: 6,
retryDelay: 1500,
}
)
if (isLoading) {
return <div className={clsx(styles.loading, styles.err)}>loading</div>
}
if (!isUserLogin() && process.env.NODE_ENV === 'production') {
const tempUrl = process.env.NEXT_PUBLIC_SITE
return (
<div className={styles.err}>
please go to &nbsp;<a href={tempUrl}>{tempUrl}</a>
</div>
)
}
if (isError) {
return (
<div className={styles.err}>
There is an error on the page, try to refresh or contact the
administrator
</div>
)
}
return (
<div className={styles.container}>{!!url && <Terminal url={url} />}</div>
)
}
@@ -0,0 +1,14 @@
import { IncomingHttpHeaders } from 'http'
export const authSession = async (header: IncomingHttpHeaders) => {
try {
if (!header?.authorization) {
return Promise.reject('缺少凭证')
}
const kubeconfig = decodeURIComponent(header.authorization)
return Promise.resolve(kubeconfig)
} catch (err) {
return Promise.reject('凭证错误')
}
}
@@ -0,0 +1,129 @@
import * as k8s from '@kubernetes/client-node'
import http from 'http'
import * as yaml from 'js-yaml'
export type CRDMeta = {
group: string // group
version: string // version
namespace: string // namespace
plural: string // type
}
export function K8sApi(config: string): k8s.KubeConfig {
const kc = new k8s.KubeConfig()
kc.loadFromString(config)
const cluster = kc.getCurrentCluster()
if (cluster !== null) {
let server: k8s.Cluster
const [inCluster, hosts] = CheckIsInCluster()
if (inCluster && hosts !== '') {
server = {
name: cluster.name,
caData: cluster.caData,
caFile: cluster.caFile,
server: hosts,
skipTLSVerify: cluster.skipTLSVerify,
}
} else {
server = {
name: cluster.name,
caData: cluster.caData,
caFile: cluster.caFile,
server: 'https://apiserver.cluster.local:6443',
skipTLSVerify: cluster.skipTLSVerify,
}
}
kc.clusters.forEach((item, i) => {
if (item.name === cluster.name) {
kc.clusters[i] = server
}
})
}
return kc
}
export function GetUserDefaultNameSpace(user: string): string {
return 'ns-' + user
}
export async function GetCRD(
kc: k8s.KubeConfig,
meta: CRDMeta,
name: string
): Promise<{
response: http.IncomingMessage
body: k8s.V1ResourceQuota
}> {
return kc
.makeApiClient(k8s.CustomObjectsApi)
.getNamespacedCustomObject(
meta.group,
meta.version,
meta.namespace,
meta.plural,
name
)
}
export async function ApplyYaml(
kc: k8s.KubeConfig,
spec_str: string
): Promise<k8s.KubernetesObject[]> {
const client = k8s.KubernetesObjectApi.makeApiClient(kc)
const specs = yaml.loadAll(spec_str) as k8s.KubernetesObject[]
const validSpecs = specs.filter((s) => s && s.kind && s.metadata)
const created: k8s.KubernetesObject[] = []
for (const spec of validSpecs) {
// this is to convince the old version of TypeScript that metadata exists even though we already filtered specs
// without metadata out
spec.metadata = spec.metadata || {}
spec.metadata.annotations = spec.metadata.annotations || {}
delete spec.metadata.annotations[
'kubectl.kubernetes.io/last-applied-configuration'
]
spec.metadata.annotations[
'kubectl.kubernetes.io/last-applied-configuration'
] = JSON.stringify(spec)
try {
// try to get the resource, if it does not exist an error will be thrown and we will end up in the catch
// block.
// TODO: temp fix
await client.read(spec as any)
// await client.read<k8s.KubernetesObject>(spec as any);
// we got the resource, so it exists, so patch it
//
// Note that this could fail if the spec refers to a custom resource. For custom resources you may need
// to specify a different patch merge strategy in the content-type header.
//
// See: https://github.com/kubernetes/kubernetes/issues/97423
const response = await client.patch(spec)
created.push(response.body)
} catch (e) {
// we did not get the resource, so it does not exist, so create it
const response = await client.create(spec)
created.push(response.body)
}
}
return created
}
export function CheckIsInCluster(): [boolean, string] {
if (
process.env.KUBERNETES_SERVICE_HOST !== undefined &&
process.env.KUBERNETES_SERVICE_HOST !== '' &&
process.env.KUBERNETES_SERVICE_PORT !== undefined &&
process.env.KUBERNETES_SERVICE_PORT !== ''
) {
return [
true,
'https://' +
process.env.KUBERNETES_SERVICE_HOST +
':' +
process.env.KUBERNETES_SERVICE_PORT,
]
}
return [false, '']
}
@@ -0,0 +1,70 @@
// http.ts
import { ApiResp } from '@/interfaces/api'
import useSessionStore from '@/stores/session'
import axios, {
AxiosRequestConfig,
AxiosResponse,
RawAxiosRequestHeaders,
} from 'axios'
const request = axios.create({
baseURL: '/',
withCredentials: true,
timeout: 30000,
})
// request interceptor
request.interceptors.request.use(
(config: AxiosRequestConfig) => {
// auto append service prefix
let _headers: RawAxiosRequestHeaders = config.headers || {}
const session = useSessionStore.getState().session
if (config.url && config.url?.startsWith('/api/')) {
_headers['Authorization'] = encodeURIComponent(session?.kubeconfig || '')
}
if (process.env.NODE_ENV === 'development') {
_headers['Authorization'] = encodeURIComponent(
process.env.NEXT_PUBLIC_MOCK_KUBECONFIG || ''
)
}
if (!config.headers || config.headers['Content-Type'] === '') {
_headers['Content-Type'] = 'application/json'
}
config.headers = _headers
return config
},
(error) => {
error.data = {}
error.data.message = 'error'
return Promise.resolve(error)
}
)
// response interceptor
request.interceptors.response.use(
(response: AxiosResponse<ApiResp>) => {
const data = response.data as ApiResp
if (!data.code || data.code < 200 || data.code > 300) {
return Promise.reject(response)
}
return response
},
(error) => {
if (!error) {
return Promise.reject({ message: '未知错误' })
}
if (axios.isCancel(error)) {
console.log('repeated request: ' + error.message)
} else {
error.data = {}
error.data.message = 'error'
}
return Promise.reject(error)
}
)
export default request
@@ -0,0 +1,59 @@
import { NextApiResponse } from 'next'
import { ApiResp } from '@/interfaces/api'
const showStatus = (status: number) => {
let message = ''
switch (status) {
case 400:
message = '请求错误(400)'
break
case 401:
message = '未授权,请重新登录(401)'
break
case 403:
message = '拒绝访问(403)'
break
case 404:
message = '请求出错(404)'
break
case 408:
message = '请求超时(408)'
break
case 500:
message = '服务器错误(500)'
break
case 501:
message = '服务未实现(501)'
break
case 502:
message = '网络错误(502)'
break
case 503:
message = '服务不可用(503)'
break
case 504:
message = '网络超时(504)'
break
case 505:
message = 'HTTP版本不受支持(505)'
break
default:
message = `连接出错(${status})!`
}
return `${message},请检查网络或联系管理员!`
}
export const jsonRes = (res: NextApiResponse, props?: ApiResp) => {
const { code = 200, message = '', data = null, error } = props || {}
let msg = message
if (code < 200 || code > 300) {
msg = error?.message || showStatus(code)
}
res.json({
code,
message: msg,
data: data || error,
})
}
@@ -0,0 +1,46 @@
import type { Session } from '@/interfaces/session'
import { sessionKey } from '@/interfaces/session'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
import * as yaml from 'js-yaml'
type SessionState = {
session: Session
setSession: (ss: Session) => void
setSessionProp: (key: keyof Session, value: any) => void
getSession: () => Session
delSession: () => void
isUserLogin: () => boolean
getKubeconfigToken: () => string
}
const useSessionStore = create<SessionState>()(
devtools(
immer((set, get) => ({
session: {} as Session,
setSession: (ss: Session) => set({ session: ss }),
setSessionProp: (key: keyof Session, value: any) => {
set((state) => {
state.session[key] = value
})
},
getSession: () => get().session,
delSession: () => {
set({ session: undefined })
},
isUserLogin: () => get().session?.user?.id !== undefined,
getKubeconfigToken: () => {
if (get().session?.kubeconfig === '') {
return ''
}
const doc = yaml.load(get().session.kubeconfig)
//@ts-ignore
return doc?.users[0]?.user?.token
},
})),
{ name: sessionKey }
)
)
export default useSessionStore
@@ -0,0 +1,64 @@
body,
h1,
h2,
h3,
h4,
hr,
p,
blockquote,
dl,
dt,
dd,
ul,
ol,
li,
pre,
form,
fieldset,
legend,
button,
input,
textarea,
th,
td,
svg {
margin: 0;
}
body {
height: 100vh;
width: 100vw;
}
iframe {
border: none;
}
#__next {
height: 100%;
}
::-webkit-scrollbar,
::-webkit-scrollbar {
width: 6px;
height: 6px;
border-radius: 6px;
}
::-webkit-scrollbar-track,
::-webkit-scrollbar-track {
background: transparent;
border-radius: 6px;
}
::-webkit-scrollbar-thumb,
::-webkit-scrollbar-thumb {
background: #bfbfbf;
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover,
::-webkit-scrollbar-thumb:hover {
background: #999;
}
::-webkit-scrollbar-corner {
background-color: transparent;
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}