mirror of
https://github.com/labring/sealos.git
synced 2026-09-01 15:38:06 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_SITE="https://cloud.sealos.io/"
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+5703
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 <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;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user