mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-13 08:00:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55c71df906 | ||
|
|
f91831a5ea | ||
|
|
18cbcb7f13 | ||
|
|
e8d3b5fe93 | ||
|
|
360cdf3f77 | ||
|
|
c3a67eed87 | ||
|
|
e8bc1310da | ||
|
|
c873c18a42 |
@@ -14,7 +14,7 @@ import (
|
||||
// @Description 获取设备基础信息
|
||||
// @Success 200 {object} dto.DeviceBaseInfo
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /toolbox/device/base [get]
|
||||
// @Router /toolbox/device/base [post]
|
||||
func (b *BaseApi) LoadDeviceBaseInfo(c *gin.Context) {
|
||||
data, err := deviceService.LoadBaseInfo()
|
||||
if err != nil {
|
||||
|
||||
@@ -77,9 +77,10 @@ type FilePathCheck struct {
|
||||
}
|
||||
|
||||
type FileWget struct {
|
||||
Url string `json:"url" validate:"required"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Url string `json:"url" validate:"required"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
IgnoreCertificate bool `json:"ignoreCertificate"`
|
||||
}
|
||||
|
||||
type FileMove struct {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -805,7 +807,25 @@ func checkImageExist(client *client.Client, image string) bool {
|
||||
}
|
||||
|
||||
func pullImages(ctx context.Context, client *client.Client, image string) error {
|
||||
out, err := client.ImagePull(ctx, image, types.ImagePullOptions{})
|
||||
options := types.ImagePullOptions{}
|
||||
repos, _ := imageRepoRepo.List()
|
||||
if len(repos) != 0 {
|
||||
for _, repo := range repos {
|
||||
if strings.HasPrefix(image, repo.DownloadUrl) && repo.Auth {
|
||||
authConfig := registry.AuthConfig{
|
||||
Username: repo.Username,
|
||||
Password: repo.Password,
|
||||
}
|
||||
encodedJSON, err := json.Marshal(authConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authStr := base64.URLEncoding.EncodeToString(encodedJSON)
|
||||
options.RegistryAuth = authStr
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := client.ImagePull(ctx, image, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -55,10 +55,15 @@ func (u *DeviceService) LoadBaseInfo() (dto.DeviceBaseInfo, error) {
|
||||
baseInfo.Hosts = loadHosts()
|
||||
baseInfo.Hostname = loadHostname()
|
||||
baseInfo.User = loadUser()
|
||||
ntp, _ := settingRepo.Get(settingRepo.WithByKey("NtpSite"))
|
||||
baseInfo.Ntp = ntp.Value
|
||||
ntp, err := settingRepo.Get(settingRepo.WithByKey("NtpSite"))
|
||||
if err == nil {
|
||||
baseInfo.Ntp = ntp.Value
|
||||
}
|
||||
|
||||
swapInfo, _ := mem.SwapMemory()
|
||||
swapInfo, err := mem.SwapMemory()
|
||||
if err != nil {
|
||||
return baseInfo, err
|
||||
}
|
||||
baseInfo.SwapMemoryTotal = swapInfo.Total
|
||||
baseInfo.SwapMemoryAvailable = swapInfo.Free
|
||||
baseInfo.SwapMemoryUsed = swapInfo.Used
|
||||
@@ -375,7 +380,7 @@ func loadUser() string {
|
||||
|
||||
func loadSwap() []dto.SwapHelper {
|
||||
var data []dto.SwapHelper
|
||||
std, err := cmd.Execf("%s swapon --show --summary", cmd.SudoHandleCmd())
|
||||
std, err := cmd.Execf("%s swapon --summary", cmd.SudoHandleCmd())
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -39,12 +39,16 @@ func (u *Fail2BanService) LoadBaseInfo() (dto.Fail2BanBaseInfo, error) {
|
||||
baseInfo.IsEnable, baseInfo.IsActive, baseInfo.IsExist = client.Status()
|
||||
if !baseInfo.IsActive {
|
||||
baseInfo.Version = "-"
|
||||
return baseInfo, nil
|
||||
} else {
|
||||
baseInfo.Version = client.Version()
|
||||
}
|
||||
baseInfo.Version = client.Version()
|
||||
conf, err := os.ReadFile(defaultFail2BanPath)
|
||||
if err != nil {
|
||||
return baseInfo, fmt.Errorf("read fail2ban conf of %s failed, err: %v", defaultFail2BanPath, err)
|
||||
if baseInfo.IsActive {
|
||||
return baseInfo, fmt.Errorf("read fail2ban conf of %s failed, err: %v", defaultFail2BanPath, err)
|
||||
} else {
|
||||
return baseInfo, nil
|
||||
}
|
||||
}
|
||||
lines := strings.Split(string(conf), "\n")
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ func (f *FileService) ChangeName(req request.FileRename) error {
|
||||
func (f *FileService) Wget(w request.FileWget) (string, error) {
|
||||
fo := files.NewFileOp()
|
||||
key := "file-wget-" + common.GetUuid()
|
||||
return key, fo.DownloadFileWithProcess(w.Url, filepath.Join(w.Path, w.Name), key)
|
||||
return key, fo.DownloadFileWithProcess(w.Url, filepath.Join(w.Path, w.Name), key, w.IgnoreCertificate)
|
||||
}
|
||||
|
||||
func (f *FileService) MvFile(m request.FileMove) error {
|
||||
|
||||
@@ -320,7 +320,7 @@ func (u *SSHService) LoadLog(req dto.SearchSSHLog) (*dto.SSHLog, error) {
|
||||
case constant.StatusSuccess:
|
||||
commandItem = fmt.Sprintf("cat %s | grep -a Accepted %s", file.Name, command)
|
||||
case constant.StatusFailed:
|
||||
commandItem = fmt.Sprintf("cat %s | grep -aE 'Failed password for|Connection closed by authenticating user' %s", file.Name, command)
|
||||
commandItem = fmt.Sprintf("cat %s | grep -aE 'Failed password for|Connection closed by authenticating user' | grep -v 'invalid' %s", file.Name, command)
|
||||
default:
|
||||
commandItem = fmt.Sprintf("cat %s | grep -aE \"(Failed password for|Connection closed by authenticating user|Accepted)\" | grep -v 'invalid' %s", file.Name, command)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func (s *ToolboxRouter) InitToolboxRouter(Router *gin.RouterGroup) {
|
||||
Use(middleware.PasswordExpired())
|
||||
baseApi := v1.ApiGroupApp.BaseApi
|
||||
{
|
||||
toolboxRouter.GET("/device/base", baseApi.LoadDeviceBaseInfo)
|
||||
toolboxRouter.POST("/device/base", baseApi.LoadDeviceBaseInfo)
|
||||
toolboxRouter.GET("/device/zone/options", baseApi.LoadTimeOption)
|
||||
toolboxRouter.POST("/device/update/conf", baseApi.UpdateDeviceConf)
|
||||
toolboxRouter.POST("/device/update/host", baseApi.UpdateDeviceHost)
|
||||
|
||||
@@ -17,44 +17,60 @@ import (
|
||||
"github.com/mozillazg/go-pinyin"
|
||||
)
|
||||
|
||||
func CompareVersion(version1 string, version2 string) bool {
|
||||
if version1 == version2 {
|
||||
return false
|
||||
}
|
||||
version1s := strings.Split(version1, ".")
|
||||
version2s := strings.Split(version2, ".")
|
||||
func CompareVersion(version1, version2 string) bool {
|
||||
v1s := extractNumbers(version1)
|
||||
v2s := extractNumbers(version2)
|
||||
|
||||
if len(version2s) > len(version1s) {
|
||||
for i := 0; i < len(version2s)-len(version1s); i++ {
|
||||
version1s = append(version1s, "0")
|
||||
}
|
||||
}
|
||||
if len(version1s) > len(version2s) {
|
||||
for i := 0; i < len(version1s)-len(version2s); i++ {
|
||||
version2s = append(version2s, "0")
|
||||
}
|
||||
}
|
||||
maxLen := max(len(v1s), len(v2s))
|
||||
v1s = append(v1s, make([]string, maxLen-len(v1s))...)
|
||||
v2s = append(v2s, make([]string, maxLen-len(v2s))...)
|
||||
|
||||
n := min(len(version1s), len(version2s))
|
||||
re := regexp.MustCompile("[0-9]+")
|
||||
for i := 0; i < n; i++ {
|
||||
sVersion1s := re.FindAllString(version1s[i], -1)
|
||||
sVersion2s := re.FindAllString(version2s[i], -1)
|
||||
if len(sVersion1s) == 0 {
|
||||
return false
|
||||
for i := 0; i < maxLen; i++ {
|
||||
v1, err1 := strconv.Atoi(v1s[i])
|
||||
v2, err2 := strconv.Atoi(v2s[i])
|
||||
if err1 != nil {
|
||||
v1 = 0
|
||||
}
|
||||
if len(sVersion2s) == 0 {
|
||||
return false
|
||||
if err2 != nil {
|
||||
v2 = 0
|
||||
}
|
||||
v1num, _ := strconv.Atoi(sVersion1s[0])
|
||||
v2num, _ := strconv.Atoi(sVersion2s[0])
|
||||
if v1num == v2num {
|
||||
continue
|
||||
if v1 != v2 {
|
||||
return v1 > v2
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractNumbers(version string) []string {
|
||||
var numbers []string
|
||||
start := -1
|
||||
for i, r := range version {
|
||||
if isDigit(r) {
|
||||
if start == -1 {
|
||||
start = i
|
||||
}
|
||||
} else {
|
||||
return v1num > v2num
|
||||
if start != -1 {
|
||||
numbers = append(numbers, version[start:i])
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
if start != -1 {
|
||||
numbers = append(numbers, version[start:])
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
|
||||
func isDigit(r rune) bool {
|
||||
return r >= '0' && r <= '9'
|
||||
}
|
||||
|
||||
func max(x, y int) int {
|
||||
if x > y {
|
||||
return x
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
func GetSortedVersions(versions []string) []string {
|
||||
|
||||
@@ -216,11 +216,12 @@ func (w *WriteCounter) SaveProcess() {
|
||||
}
|
||||
}
|
||||
|
||||
func (f FileOp) DownloadFileWithProcess(url, dst, key string) error {
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
func (f FileOp) DownloadFileWithProcess(url, dst, key string, ignoreCertificate bool) error {
|
||||
client := &http.Client{}
|
||||
if ignoreCertificate {
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
request, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -10102,7 +10102,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"/toolbox/device/base": {
|
||||
"get": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
|
||||
@@ -10095,7 +10095,7 @@
|
||||
}
|
||||
},
|
||||
"/toolbox/device/base": {
|
||||
"get": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
|
||||
@@ -11190,7 +11190,7 @@ paths:
|
||||
formatZH: 清理系统垃圾文件
|
||||
paramKeys: []
|
||||
/toolbox/device/base:
|
||||
get:
|
||||
post:
|
||||
description: 获取设备基础信息
|
||||
responses:
|
||||
"200":
|
||||
|
||||
@@ -109,6 +109,7 @@ export namespace File {
|
||||
path: string;
|
||||
name: string;
|
||||
url: string;
|
||||
ignoreCertificate?: boolean;
|
||||
}
|
||||
|
||||
export interface FileWgetRes {
|
||||
|
||||
@@ -6,16 +6,16 @@ import { TimeoutEnum } from '@/enums/http-enum';
|
||||
|
||||
// device
|
||||
export const getDeviceBase = () => {
|
||||
return http.get<Toolbox.DeviceBaseInfo>(`/toolbox/device/base`);
|
||||
return http.post<Toolbox.DeviceBaseInfo>(`/toolbox/device/base`, {}, TimeoutEnum.T_60S);
|
||||
};
|
||||
export const loadTimeZoneOptions = () => {
|
||||
return http.get<Array<string>>(`/toolbox/device/zone/options`);
|
||||
};
|
||||
export const updateDevice = (key: string, value: string) => {
|
||||
return http.post(`/toolbox/device/update/conf`, { key: key, value: value });
|
||||
return http.post(`/toolbox/device/update/conf`, { key: key, value: value }, TimeoutEnum.T_60S);
|
||||
};
|
||||
export const updateDeviceHost = (param: Array<Toolbox.TimeZoneOptions>) => {
|
||||
return http.post(`/toolbox/device/update/host`, param);
|
||||
return http.post(`/toolbox/device/update/host`, param, TimeoutEnum.T_60S);
|
||||
};
|
||||
export const updateDevicePasswd = (user: string, passwd: string) => {
|
||||
return http.post(`/toolbox/device/update/passwd`, { user: user, passwd: Base64.encode(passwd) });
|
||||
|
||||
@@ -103,7 +103,7 @@ const rowName = ref('');
|
||||
const data = ref();
|
||||
const loading = ref(false);
|
||||
const paths = ref<string[]>([]);
|
||||
const req = reactive({ path: '/', expand: true, page: 1, pageSize: 300 });
|
||||
const req = reactive({ path: '/', expand: true, page: 1, pageSize: 300, showHidden: true });
|
||||
const selectRow = ref();
|
||||
const popoverVisible = ref(false);
|
||||
|
||||
@@ -196,13 +196,17 @@ const search = async (req: File.ReqFile) => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
req.path = props.path;
|
||||
if (props.path != '') {
|
||||
req.path = props.path;
|
||||
}
|
||||
rowName.value = '';
|
||||
search(req);
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
req.path = props.path;
|
||||
if (props.path != '') {
|
||||
req.path = props.path;
|
||||
}
|
||||
search(req);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1105,6 +1105,9 @@ const message = {
|
||||
wordWrap: 'Automatically wrap',
|
||||
deleteHelper2:
|
||||
'Are you sure you want to delete the selected file? The deletion operation cannot be rolled back',
|
||||
ignoreCertificate: 'Ignore Certificate',
|
||||
ignoreCertificateHelper:
|
||||
'Ignoring untrusted certificates during downloads may lead to data leakage or tampering. Please use this option with caution, only when trusting the download source.',
|
||||
},
|
||||
ssh: {
|
||||
autoStart: 'Auto Start',
|
||||
|
||||
@@ -1051,6 +1051,9 @@ const message = {
|
||||
fileRecycleBinMsg: '已{0}回收站',
|
||||
wordWrap: '自動換行',
|
||||
deleteHelper2: '確定刪除所選檔案? 刪除操作不可回滾',
|
||||
ignoreCertificate: '忽略不可信證書',
|
||||
ignoreCertificateHelper:
|
||||
'下載時忽略不可信證書可能導致數據洩露或篡改。請謹慎使用此選項,僅在信任下載源的情況下啟用',
|
||||
},
|
||||
ssh: {
|
||||
autoStart: '開機自啟',
|
||||
|
||||
@@ -1052,6 +1052,9 @@ const message = {
|
||||
fileRecycleBinMsg: '已{0}回收站',
|
||||
wordWrap: '自动换行',
|
||||
deleteHelper2: '确定删除所选文件?删除操作不可回滚',
|
||||
ignoreCertificate: '忽略不可信证书',
|
||||
ignoreCertificateHelper:
|
||||
'下载时忽略不可信证书可能导致数据泄露或篡改。请谨慎使用此选项,仅在信任下载源的情况下启用',
|
||||
},
|
||||
ssh: {
|
||||
autoStart: '开机自启',
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
{{ $t('home.free') }}: {{ formatNumber(currentInfo.swapMemoryAvailable / 1024 / 1024) }} MB
|
||||
</el-tag>
|
||||
<el-tag class="tagClass">
|
||||
{{ $t('home.percent') }}: {{ formatNumber(currentInfo.swapMemoryUsedPercent * 100) }}%
|
||||
{{ $t('home.percent') }}: {{ formatNumber(currentInfo.swapMemoryUsedPercent) }}%
|
||||
</el-tag>
|
||||
</div>
|
||||
<template #reference>
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input v-model="addForm.name"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="addForm.ignoreCertificate">
|
||||
{{ $t('file.ignoreCertificate') }}
|
||||
</el-checkbox>
|
||||
<span class="input-help">{{ $t('file.ignoreCertificateHelper') }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -67,6 +73,7 @@ const addForm = reactive({
|
||||
url: '',
|
||||
path: '',
|
||||
name: '',
|
||||
ignoreCertificate: false,
|
||||
});
|
||||
|
||||
const em = defineEmits(['close']);
|
||||
@@ -111,6 +118,7 @@ const acceptParams = (props: WgetProps) => {
|
||||
addForm.path = props.path;
|
||||
open.value = true;
|
||||
submitData.value = false;
|
||||
addForm.ignoreCertificate = false;
|
||||
};
|
||||
|
||||
defineExpose({ acceptParams });
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<DrawerHeader header="DNS" :back="handleClose" />
|
||||
</template>
|
||||
|
||||
<el-row type="flex" justify="center">
|
||||
<el-row type="flex" justify="center" v-loading="loading">
|
||||
<el-col :span="22">
|
||||
<el-alert
|
||||
:title="$t('toolbox.device.dnsAlert')"
|
||||
@@ -24,7 +24,6 @@
|
||||
label-position="top"
|
||||
@submit.prevent
|
||||
:model="form"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-form-item label="DNS" prop="dns">
|
||||
<el-input
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<DrawerHeader :header="$t('toolbox.device.hostname')" :back="handleClose" />
|
||||
</template>
|
||||
|
||||
<el-row type="flex" justify="center">
|
||||
<el-row type="flex" justify="center" v-loading="loading">
|
||||
<el-col :span="22">
|
||||
<el-alert
|
||||
:title="$t('toolbox.device.hostnameHelper')"
|
||||
@@ -13,7 +13,7 @@
|
||||
:closable="false"
|
||||
type="warning"
|
||||
/>
|
||||
<el-form ref="formRef" label-position="top" :model="form" @submit.prevent v-loading="loading">
|
||||
<el-form ref="formRef" label-position="top" :model="form" @submit.prevent>
|
||||
<el-form-item
|
||||
:label="$t('toolbox.device.hostname')"
|
||||
prop="hostname"
|
||||
@@ -76,6 +76,7 @@ const onSaveHostame = async (formEl: FormInstance | undefined) => {
|
||||
type: 'info',
|
||||
},
|
||||
).then(async () => {
|
||||
loading.value = true;
|
||||
await updateDevice('Hostname', form.hostname)
|
||||
.then(async () => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<DrawerHeader header="Hosts" :back="handleClose" />
|
||||
</template>
|
||||
|
||||
<el-row type="flex" justify="center">
|
||||
<el-row type="flex" justify="center" v-loading="loading">
|
||||
<el-col :span="22">
|
||||
<el-radio-group v-model="confShowType" @change="changeMode">
|
||||
<el-radio-button label="base">{{ $t('database.baseConf') }}</el-radio-button>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div>
|
||||
<el-drawer v-model="passwordVisible" :destroy-on-close="true" :close-on-click-modal="false" size="30%">
|
||||
<template #header>
|
||||
<DrawerHeader :header="$t('setting.changePassword')" :back="handleClose" />
|
||||
</template>
|
||||
|
||||
<el-row type="flex" justify="center">
|
||||
<el-row type="flex" justify="center" v-loading="loading">
|
||||
<el-col :span="22">
|
||||
<el-form ref="formRef" label-position="top" :model="form" :rules="passRules">
|
||||
<el-alert
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" :close-on-click-modal="false" size="50%">
|
||||
<template #header>
|
||||
<DrawerHeader header="Swap" :back="handleClose" />
|
||||
|
||||
@@ -40,17 +40,14 @@
|
||||
</div>
|
||||
|
||||
<div v-if="form.isExist">
|
||||
<el-card v-if="!form.isActive" class="mask-prompt">
|
||||
<span>{{ $t('toolbox.fail2ban.unActive') }}</span>
|
||||
</el-card>
|
||||
<LayoutContent title="Fail2ban" :divider="true" :class="{ mask: !form.isActive }">
|
||||
<LayoutContent title="Fail2ban" :divider="true">
|
||||
<template #toolbar>
|
||||
<el-row>
|
||||
<el-col :span="16">
|
||||
<el-button type="primary" plain @click="onLoadList('ignore')">
|
||||
<el-button :disabled="!form.isActive" type="primary" plain @click="onLoadList('ignore')">
|
||||
{{ $t('toolbox.fail2ban.ignoreIP') }}
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="onLoadList('banned')">
|
||||
<el-button :disabled="!form.isActive" type="primary" plain @click="onLoadList('banned')">
|
||||
{{ $t('toolbox.fail2ban.bannedIP') }}
|
||||
</el-button>
|
||||
</el-col>
|
||||
|
||||
@@ -89,7 +89,7 @@ const setting = () => {
|
||||
};
|
||||
|
||||
const toDoc = async () => {
|
||||
window.open('https://1panel.cn/docs/user_manual/hosts/supervisor/', '_blank', 'noopener,noreferrer');
|
||||
window.open('https://1panel.cn/docs/user_manual/toolbox/supervisor/', '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
|
||||
Reference in New Issue
Block a user