initial commit

This commit is contained in:
Alireza Ahmadi
2024-02-13 01:17:03 +01:00
commit f40b27fd8b
136 changed files with 16023 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11
+5
View File
@@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
],
rules: {
'vue/multi-word-component-names': 'off',
},
}
+23
View File
@@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
/bin
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+69
View File
@@ -0,0 +1,69 @@
# base
## Project setup
```
# yarn
yarn
# npm
npm install
# pnpm
pnpm install
# bun
bun install
```
### Compiles and hot-reloads for development
```
# yarn
yarn dev
# npm
npm run dev
# pnpm
pnpm dev
# bun
pnpm run dev
```
### Compiles and minifies for production
```
# yarn
yarn build
# npm
npm run build
# pnpm
pnpm build
# bun
pnpm run build
```
### Lints and fixes files
```
# yarn
yarn lint
# npm
npm run lint
# pnpm
pnpm lint
# bun
pnpm run lint
```
### Customize configuration
See [Configuration Reference](https://vitejs.dev/config/).
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="assets/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>S-UI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+4964
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore"
},
"dependencies": {
"@mdi/font": "7.0.96",
"axios": "^1.6.5",
"chart.js": "^4.4.1",
"clipboard": "^2.0.11",
"core-js": "^3.29.0",
"pinia": "^2.1.7",
"qrcode.vue": "^3.4.1",
"roboto-fontface": "*",
"vue": "^3.2.0",
"vue-chartjs": "^5.3.0",
"vue-i18n": "^9.8.0",
"vue-router": "^4.0.0",
"vue3-persian-datetime-picker": "^1.2.2",
"vuetify": "^3.0.0"
},
"devDependencies": {
"@babel/types": "^7.21.4",
"@types/node": "^18.15.0",
"@vitejs/plugin-vue": "^4.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"eslint": "^8.22.0",
"eslint-plugin-vue": "^9.3.0",
"material-design-icons-iconfont": "^6.7.0",
"sass": "^1.60.0",
"typescript": "^5.0.0",
"unplugin-fonts": "^1.0.3",
"vite": "^4.2.0",
"vite-plugin-vuetify": "^1.0.0",
"vue-tsc": "^1.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+34
View File
@@ -0,0 +1,34 @@
<template>
<v-overlay
:model-value="loading"
persistent
content-class="text-center"
class="align-center justify-center"
>
<v-progress-circular
indeterminate
size="64"
></v-progress-circular>
<br />
{{ $t('loading') }}
</v-overlay>
<Message />
<router-view />
</template>
<script lang="ts" setup>
import Message from '@/components/message.vue'
import { inject, ref, Ref } from 'vue'
const loading:Ref = inject('loading')?? ref(false)
// Change page title
document.title = "S-UI " + document.location.hostname
</script>
<style>
.v-overlay .v-list-item,
.v-field__input {
direction: ltr;
}
</style>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="1.019 0.0225 45.9789 46.9775" width="45.9789" height="46.9775" xmlns="http://www.w3.org/2000/svg">
<g featurekey="symbolFeature-0" transform="matrix(0.4545450210571289, 0, 0, 0.4545450210571289, 0.7917079329490662, 1.7009549140930176)" fill="#737373">
<g xmlns="http://www.w3.org/2000/svg">
<g>
<path d="M50,99.658L0.5,70.699V29.301L50,0.341l49.5,28.959v41.398L50,99.658z M2.5,69.553L50,97.342l47.5-27.789V30.448L50,2.659 L2.5,30.448V69.553z"/>
</g>
<g>
<polygon points="51,98.376 49,98.376 49,58.822 0.995,30.738 2.005,29.011 50,57.091 97.995,29.011 99.005,30.738 51,58.822 "/>
</g>
<g>
<polyline points="28.494,14.082 76.994,42.457 71.506,45.667 23.006,17.292 "/>
<polygon points="71.507,46.246 71.254,46.098 22.754,17.724 23.259,16.861 71.507,45.087 76.003,42.457 28.241,14.514 28.746,13.65 77.983,42.457 "/>
</g>
<g>
<polyline points="71.506,45.667 71.506,57.982 71.51,57.982 76.993,54.775 76.993,42.457 "/>
<polyline points="71.006,45.667 72.006,45.667 72.006,57.113 76.493,54.487 76.493,42.457 77.493,42.457 77.493,55.062 71.646,58.482 71,58.85 "/>
</g>
</g>
</g>
<g featurekey="nameFeature-0" transform="matrix(1.6160469055175781, 0, 0, 1.6160469055175781, 3.2854819297790527, -21.369783401489258)" fill="#a6a6a6">
<path d="M10.904 40.4028 c-5.316 0 -9.2256 -3.048 -9.2256 -6.6192 c0 -1.8592 1.2372 -2.8152 2.5116 -2.8152 c1.0924 0 2.2288 0.7184 2.2288 2.1488 c0 1.4428 -1.4112 1.9972 -1.4112 2.9972 c0 1.782 2.974 3.0552 5.338 3.0552 c3.244 0 6.382 -1.5736 6.382 -5.102 c0 -6.2716 -14.403 -3.6536 -14.403 -13.229 c0 -5.0924 4.3436 -7.6012 9.9036 -7.6012 c4.7364 0 8.9656 2.6496 8.9656 6.1048 c0 1.946 -1.2372 2.9308 -2.4828 2.9308 c-1.1216 0 -2.258 -0.7476 -2.258 -2.178 c0 -1.5584 1.382 -1.8812 1.382 -2.8812 c0 -1.6952 -2.974 -2.7436 -5.3092 -2.7436 c-3.04 0 -5.9296 1.1848 -5.9296 4.6496 c0 5.866 15.193 3.7708 15.193 13.345 c0 4.0208 -3.8304 7.938 -10.886 7.938 z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+132
View File
@@ -0,0 +1,132 @@
<template>
<v-text-field
id="expiry"
:label="$t('date.expiry')"
v-model="dateFormatted"
prepend-inner-icon="mdi-calendar"
readonly
hide-details
></v-text-field>
<DatePicker
v-model="Input"
@input="Input=$event"
:locale="$i18n.locale"
element="expiry"
compact-time
type="datetime">
<template v-slot:next-month>
<v-icon icon="mdi-chevron-right" />
</template>
<template v-slot:prev-month>
<v-icon icon="mdi-chevron-left" />
</template>
<template #submit-btn="{ submit, canSubmit }">
<v-btn
:disabled="!canSubmit"
@click="submit"
>{{ $t('submit') }}</v-btn>
</template>
<template #cancel-btn="{ vm }">
<v-btn
@click="reset(vm)"
>{{ $t('reset') }}</v-btn>
</template>
<template #now-btn="{ goToday }">
<v-btn
@click="goToday"
>{{ $t('now') }}</v-btn>
</template>
</DatePicker>
</template>
<script lang="ts">
import DatePicker from 'vue3-persian-datetime-picker'
import { i18n } from '@/locales'
export default {
props: ['expiry'],
emits: ['submit'],
data() {
return {
menu: false,
input: new Date(),
}
},
components: { DatePicker },
computed: {
dateFormatted() {
if (this.expDate == 0) return i18n.global.t('unlimited')
const date = new Date(this.expDate*1000)
return date.toLocaleString(i18n.global.locale.value)
},
expDate() {
return parseInt(this.expiry?? 0)
},
Input: {
get() { return this.expDate == 0 ? new Date() : new Date(this.expDate*1000) },
set(v:string) {
this.input = new Date(v)
this.submit()
}
}
},
methods: {
updateInput(v:Date) {
this.input = v
},
setNow() {
this.input = new Date()
},
submit() {
this.$emit('submit',Math.floor(this.input.getTime()/1000))
},
reset(vm:any) {
this.$emit('submit',0)
this.input = new Date()
vm.visible = false
}
},
watch: {
menu(v) {
if (v) {
this.input = this.expiry == 0 ? new Date() : new Date(this.expDate*1000)
}
}
}
};
</script>
<style>
.vpd-addon-list,
.vpd-addon-list-item {
background-color: rgb(var(--v-theme-background));
border-color: rgb(var(--v-theme-background));
}
.vpd-content {
background-color: rgb(var(--v-theme-background));
}
.vpd-addon-list-item.vpd-selected,
.vpd-addon-list-item:hover {
background-color: rgb(var(--v-theme-primary));
}
.vpd-close-addon {
color: rgb(var(--v-theme-on-surface));
background-color: transparent;
}
.vpd-controls {
overflow-x: hidden;
}
.vpd-month-label {
width: auto;
}
.vpd-actions button:hover {
background-color: transparent;
}
.vpd-wrapper[data-type=datetime].vpd-compact-time .vpd-time {
border-top: 0;
}
.vpd-time .vpd-time-h .vpd-counter-item,
.vpd-time .vpd-time-m .vpd-counter-item {
vertical-align: top;
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<template>
<v-card subtitle="Dial" style="background-color: inherit;">
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionDetour">
<v-text-field
label="Forward to Outbound tag"
hide-details
v-model="dial.detour"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionBind">
<v-text-field
label="Bind to Network Interface"
hide-details
v-model="dial.bind_interface"></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionIPV4">
<v-text-field
label="Bind to IPv4"
hide-details
v-model="dial.inet4_bind_address"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionIPV6">
<v-text-field
label="Bind to IPv6"
hide-details
v-model="dial.inet6_bind_address"></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionRM">
<v-text-field
label="Linux Routing Mark"
hide-details
type="number"
min="0"
v-model.number="routingMark"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionRA">
<v-switch v-model="dial.reuse_addr" color="primary" label="Reuse listener address" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="optionTCP">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="dial.tcp_fast_open" color="primary" label="TCP Fast Open" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="dial.tcp_multi_path" color="primary" label="TCP Multi Path" hide-details></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionUDP">
<v-switch v-model="dial.udp_fragment" color="primary" label="UDP Fragment" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="optionCT">
<v-text-field
label="Connection Timeout"
hide-details
type="number"
min="1"
suffix="s"
v-model.number="connectTimeout"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionDS">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
clearable
@click:clear="delete dial.domain_strategy"
width="100"
label="Domain to IP Strategy"
:items="['prefer_ipv4','prefer_ipv6','ipv4_only','ipv6_only']"
v-model="dial.domain_strategy">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Fallback Timeout"
hide-details
type="number"
min="50"
step="50"
suffix="ms"
v-model.number="fallbackDelay"></v-text-field>
</v-col>
</v-row>
<v-card-actions class="pt-0">
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details>Dial Options</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionDetour" color="primary" label="Detour" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionBind" color="primary" label="Bind Interface" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionIPV4" color="primary" label="Bind to IPv4" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionIPV6" color="primary" label="Bind to IPv6" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionRM" color="primary" label="Routing Mark" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionRA" color="primary" label="Reuse Address" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionTCP" color="primary" label="TCP Options" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionUDP" color="primary" label="UDP Options" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionCT" color="primary" label="Connection Timeout" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionDS" color="primary" label="Domain Strategy" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
export default {
props: ['dial'],
data() {
return {
menu: false
}
},
computed: {
fallbackDelay: {
get() { return this.$props.dial.fallback_delay ? parseInt(this.$props.dial.fallback_delay.replace('ms','')) : 300 },
set(newValue:number) { this.$props.dial.fallback_delay = newValue > 0 ? newValue + 'ms' : '300ms' }
},
connectTimeout: {
get() { return this.$props.dial.connect_timeout ? parseInt(this.$props.dial.connect_timeout.replace('s','')) : 5 },
set(newValue:number) { this.$props.dial.connect_timeout = newValue > 0 ? newValue + 's' : '5s' }
},
routingMark: {
get() { return this.$props.dial.routing_mark?? 0 },
set(newValue:number) { this.$props.dial.routing_mark = newValue > 0 ? newValue : 0 }
},
optionDetour: {
get(): boolean { return this.$props.dial.detour != undefined },
set(v:boolean) { v ? this.$props.dial.detour = '' : delete this.$props.dial.detour }
},
optionBind: {
get(): boolean { return this.$props.dial.bind_interface != undefined },
set(v:boolean) { v ? this.$props.dial.bind_interface = '' : delete this.$props.dial.bind_interface }
},
optionIPV4: {
get(): boolean { return this.$props.dial.inet4_bind_address != undefined },
set(v:boolean) { v ? this.$props.dial.inet4_bind_address = '' : delete this.$props.dial.inet4_bind_address }
},
optionIPV6: {
get(): boolean { return this.$props.dial.inet6_bind_address != undefined },
set(v:boolean) { v ? this.$props.dial.inet6_bind_address = '' : delete this.$props.dial.inet6_bind_address }
},
optionRM: {
get(): boolean { return this.$props.dial.routing_mark != undefined },
set(v:boolean) { v ? this.$props.dial.routing_mark = 0 : delete this.$props.dial.routing_mark }
},
optionRA: {
get(): boolean { return this.$props.dial.reuse_addr != undefined },
set(v:boolean) { v ? this.$props.dial.reuse_addr = true : delete this.$props.dial.reuse_addr }
},
optionTCP: {
get(): boolean {
return this.$props.dial.tcp_fast_open != undefined &&
this.$props.dial.tcp_multi_path != undefined
},
set(v:boolean) {
if (v) {
this.$props.dial.tcp_fast_open = false
this.$props.dial.tcp_multi_path = false
} else {
delete this.$props.dial.tcp_fast_open
delete this.$props.dial.tcp_multi_path
}
}
},
optionUDP: {
get(): boolean { return this.$props.dial.udp_fragment != undefined },
set(v:boolean) { v ? this.$props.dial.udp_fragment = true : delete this.$props.dial.udp_fragment }
},
optionCT: {
get(): boolean { return this.$props.dial.connect_timeout != undefined },
set(v:boolean) { v ? this.$props.dial.connect_timeout = '5s' : delete this.$props.dial.connect_timeout }
},
optionDS: {
get(): boolean { return this.$props.dial.domain_strategy != undefined },
set(v:boolean) {
if (v) {
this.$props.dial.domain_strategy = 'prefer_ipv4'
this.$props.dial.fallback_delay = '300ms'
} else {
delete this.$props.dial.domain_strategy
delete this.$props.dial.fallback_delay
}
}
}
}
}
</script>
+75
View File
@@ -0,0 +1,75 @@
<template>
<v-card :subtitle="$t('in.multiplex')">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" label="Enable Multiplex" v-model="muxEnable" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="mux.enabled">
<v-switch color="primary" label="Reject Non-Padded" v-model="mux.padding" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="mux.enabled">
<v-switch color="primary" label="Enable Brutal" v-model="burtalEnable" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="mux.brutal?.enabled">
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Uplink Bandwidth"
hide-details
type="number"
suffix="Mbps"
v-model.number="up_mbps">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Downlink Bandwidth"
hide-details
type="number"
suffix="Mbps"
min="0"
v-model.number="down_mbps">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import { iMultiplex } from '@/types/inMultiplex'
export default {
props: ['inbound'],
data() {
return {}
},
computed: {
mux(): iMultiplex {
return <iMultiplex> this.$props.inbound.multiplex
},
muxEnable: {
get(): boolean { return this.$props.inbound.multiplex ? this.mux.enabled : false },
set(newValue:boolean) { this.$props.inbound.multiplex = newValue ? { enabled: newValue } : {} }
},
burtalEnable: {
get(): boolean { return this.mux.brutal ? this.mux.brutal.enabled : false },
set(newValue:boolean) { this.mux.brutal = { enabled: newValue, up_mbps: 100, down_mbps: 100 } }
},
down_mbps: {
get() { return this.mux.brutal && this.mux.brutal.down_mbps ? this.mux.brutal.down_mbps : 0 },
set(newValue:any) {
if (this.mux.brutal){
this.mux.brutal.down_mbps = newValue.length != 0 ? newValue : 0
}
}
},
up_mbps: {
get() { return this.mux.brutal && this.mux.brutal.up_mbps ? this.mux.brutal.up_mbps : 0 },
set(newValue:any) {
if (this.mux.brutal){
this.mux.brutal.up_mbps = newValue.length != 0 ? newValue : 0
}
}
},
}
}
</script>
+214
View File
@@ -0,0 +1,214 @@
<template>
<v-card :subtitle="$t('in.tls')">
<v-row v-if="tlsOptional">
<v-col cols="auto">
<v-switch color="primary" :label="$t('tls.enable')" v-model="tlsEnable" hide-details></v-switch>
</v-col>
</v-row>
<template v-if="tls.enabled">
<v-row>
<v-col cols="auto">
<v-btn-toggle v-model="usePath"
class="rounded-xl"
density="compact"
variant="outlined"
shaped
mandatory>
<v-btn
@click="tls.key=undefined; tls.certificate=undefined"
>{{ $t('tls.usePath') }}</v-btn>
<v-btn
@click="tls.key_path=undefined; tls.certificate_path=undefined"
>{{ $t('tls.useText') }}</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
<v-row v-if="usePath == 0">
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('tls.certPath')"
hide-details
v-model="tls.certificate_path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('tls.keyPath')"
hide-details
v-model="tls.key_path">
</v-text-field>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12" sm="6">
<v-textarea
:label="$t('tls.cert')"
hide-details
v-model="certText">
</v-textarea>
</v-col>
<v-col cols="12" sm="6">
<v-textarea
:label="$t('tls.key')"
hide-details
v-model="keyText">
</v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="tls.server_name != undefined">
<v-text-field
label="SNI"
hide-details
v-model="tls.server_name">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="tls.alpn">
<v-select
hide-details
label="ALPN"
multiple
:items="alpn"
v-model="tls.alpn">
</v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="tls.min_version">
<v-select
hide-details
label="Minimum Version"
:items="tlsVersions"
v-model="tls.min_version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="tls.max_version">
<v-select
hide-details
label="Maximum Version"
:items="tlsVersions"
v-model="tls.max_version">
</v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="8" v-if="tls.cipher_suites != undefined">
<v-select
hide-details
label="Cipher Suites"
multiple
:items="cipher_suites"
v-model="tls.cipher_suites">
</v-select>
</v-col>
</v-row>
</template>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start" v-if="tls.enabled">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details>TLS Options</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionSNI" color="primary" label="SNI" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionALPN" color="primary" label="ALPN" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionMinV" color="primary" label="Min Version" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionMaxV" color="primary" label="Max Version" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionCS" color="primary" label="Cipher Suites" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import { iTls, defaultInTls } from '@/types/inTls'
export default {
props: ['inbound'],
data() {
return {
menu: false,
usePath: 0,
defaults: defaultInTls,
alpn: [
{ title: "H3", value: 'HTTP/3' },
{ title: "H2", value: 'HTTP/2' },
{ title: "Http1.1", value: 'HTTP/1.1' },
],
tlsVersions: [ '1.0', '1.1', '1.2', '1.3' ],
cipher_suites: [
{ title: "Automatic", value: "" },
{ title: "RSA-AES128-CBC-SHA", value: "TLS_RSA_WITH_AES_128_CBC_SHA" },
{ title: "RSA-AES256-CBC-SHA", value: "TLS_RSA_WITH_AES_256_CBC_SHA" },
{ title: "RSA-AES128-GCM-SHA256", value: "TLS_RSA_WITH_AES_128_GCM_SHA256" },
{ title: "RSA-AES256-GCM-SHA384", value: "TLS_RSA_WITH_AES_256_GCM_SHA384" },
{ title: "AES128-GCM-SHA256", value: "TLS_AES_128_GCM_SHA256" },
{ title: "AES256-GCM-SHA384", value: "TLS_AES_256_GCM_SHA384" },
{ title: "CHACHA20-POLY1305-SHA256", value: "TLS_CHACHA20_POLY1305_SHA256" },
{ title: "ECDHE-ECDSA-AES128-CBC-SHA", value: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA" },
{ title: "ECDHE-ECDSA-AES256-CBC-SHA", value: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA" },
{ title: "ECDHE-RSA-AES128-CBC-SHA", value: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" },
{ title: "ECDHE-RSA-AES256-CBC-SHA", value: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA" },
{ title: "ECDHE-ECDSA-AES128-GCM-SHA256", value: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" },
{ title: "ECDHE-ECDSA-AES256-GCM-SHA384", value: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" },
{ title: "ECDHE-RSA-AES128-GCM-SHA256", value: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" },
{ title: "ECDHE-RSA-AES256-GCM-SHA384", value: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" },
{ title: "ECDHE-ECDSA-CHACHA20-POLY1305-SHA256", value: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" },
{ title: "ECDHE-RSA-CHACHA20-POLY1305-SHA256", value: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" }
]
}
},
computed: {
tls(): iTls {
return <iTls> this.$props.inbound.tls
},
tlsEnable: {
get() { return Object.hasOwn(this.$props.inbound.tls, 'enabled') ? this.tls.enabled : false },
set(newValue: boolean) { this.$props.inbound.tls = newValue ? { enabled: true } : {} }
},
tlsOptional(): boolean {
return !['hysteria','hysteria2','tuic','naive'].includes(this.$props.inbound.type)
},
certText: {
get(): string { return this.tls.certificate ? this.tls.certificate.join('\n') : '' },
set(newValue:string) { this.tls.certificate = newValue.split('\n') }
},
keyText: {
get(): string { return this.tls.key ? this.tls.key.join('\n') : '' },
set(newValue:string) { this.tls.key = newValue.split('\n') }
},
optionSNI: {
get(): boolean { return this.tls.server_name != undefined },
set(v:boolean) { this.$props.inbound.tls.server_name = v ? '' : undefined }
},
optionALPN: {
get(): boolean { return this.tls.alpn != undefined },
set(v:boolean) { this.$props.inbound.tls.alpn = v ? defaultInTls.alpn : undefined }
},
optionMinV: {
get(): boolean { return this.tls.min_version != undefined },
set(v:boolean) { this.$props.inbound.tls.min_version = v ? defaultInTls.min_version : undefined }
},
optionMaxV: {
get(): boolean { return this.tls.max_version != undefined },
set(v:boolean) { this.$props.inbound.tls.max_version = v ? defaultInTls.max_version : undefined }
},
optionCS: {
get(): boolean { return this.tls.cipher_suites != undefined },
set(v:boolean) { this.$props.inbound.tls.cipher_suites = v ? defaultInTls.cipher_suites : undefined }
}
}
}
</script>
+154
View File
@@ -0,0 +1,154 @@
<template>
<v-card subtitle="Listen">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('in.addr')"
hide-details
required
v-model="inbound.listen">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('in.port')"
hide-details
type="number"
required
v-model.number="inbound.listen_port"></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4" v-if="optionDetour">
<v-text-field
label="Forward to Inbound tag"
hide-details
v-model="inbound.detour"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="inbound.sniff" color="primary" :label="$t('in.sniffing')" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="inbound.sniff">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="inbound.sniff_override_destination" color="primary" label="Override Sniffed Domain" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Sniffing Timeout"
hide-details
type="number"
min="50"
step="50"
suffix="ms"
v-model.number="sniffTimeout"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionTCP">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="inbound.tcp_fast_open" color="primary" label="TCP Fast Open" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="inbound.tcp_multi_path" color="primary" label="TCP Multi Path" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="optionUDP">
<v-col cols="12" sm="6" md="4">
<v-switch v-model="inbound.udp_fragment" color="primary" label="UDP Fragment" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="UDP NAT expiration"
hide-details
type="number"
min="1"
suffix="Min"
v-model.number="udpTimeout"></v-text-field>
</v-col>
</v-row>
<v-row v-if="optionDS">
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
width="100"
label="Domain to IP Strategy"
:items="['prefer_ipv4','prefer_ipv6','ipv4_only','ipv6_only']"
v-model="inbound.domain_strategy">
</v-select>
</v-col>
</v-row>
<v-card-actions class="pt-0">
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details>Listen Options</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionTCP" color="primary" label="TCP Options" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionUDP" color="primary" label="UDP Options" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionDetour" color="primary" label="Detour" hide-details></v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="optionDS" color="primary" label="Domain Strategy" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
export default {
props: ['inbound'],
data() {
return {
menu: false
}
},
computed: {
udpTimeout: {
get() { return this.$props.inbound.udp_timeout ? parseInt(this.$props.inbound.udp_timeout.replace('m','')) : 5 },
set(newValue:number) { this.$props.inbound.udp_timeout = newValue > 0 ? newValue + 'm' : '5m' }
},
sniffTimeout: {
get() { return this.$props.inbound.sniff_timeout ? parseInt(this.$props.inbound.sniff_timeout.replace('ms','')) : 300 },
set(newValue:number) { this.$props.inbound.sniff_timeout = newValue > 0 ? newValue + 'ms' : '300ms' }
},
optionTCP: {
get(): boolean {
return this.$props.inbound.tcp_fast_open != undefined &&
this.$props.inbound.tcp_multi_path != undefined
},
set(v:boolean) {
this.$props.inbound.tcp_fast_open = v ? false : undefined
this.$props.inbound.tcp_multi_path = v ? false : undefined
}
},
optionUDP: {
get(): boolean {
return this.$props.inbound.udp_fragment != undefined &&
this.$props.inbound.udp_timeout != undefined
},
set(v:boolean) {
this.$props.inbound.udp_fragment = v ? false : undefined
this.$props.inbound.udp_timeout = v ? false : undefined
}
},
optionDetour: {
get(): boolean { return this.$props.inbound.detour != undefined },
set(v:boolean) { this.$props.inbound.detour = v ? '' : undefined }
},
optionDS: {
get(): boolean { return this.$props.inbound.domain_strategy != undefined },
set(v:boolean) { this.$props.inbound.domain_strategy = v ? 'prefer_ipv4' : undefined }
}
}
}
</script>
+218
View File
@@ -0,0 +1,218 @@
<template>
<v-container class="fill-height">
<v-responsive :class="reloadItems.length>0 ? 'fill-height text-center' : 'align-center'" >
<v-row class="d-flex align-center justify-center">
<v-col cols="auto">
<v-img src="@/assets/logo.svg" :width="reloadItems.length>0 ? 100 : 200"></v-img>
</v-col>
</v-row>
<v-row class="d-flex align-center justify-center">
<v-col cols="auto">
<v-dialog v-model="menu" :close-on-content-click="false" transition="scale-transition" max-width="800">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" variant="tonal">{{ $t('main.tiles') }} <v-icon icon="mdi-star-plus" /></v-btn>
</template>
<v-card rounded="xl">
<v-card-title>
<v-row>
<v-col>
{{ $t('main.tiles') }}
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto"><v-icon icon="mdi-close" @click="menu = false"></v-icon></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-row>
<v-col cols="12" sm="6" md="4" v-for="items in menuItems">
<v-card variant="flat" :title="items.title">
<v-list v-for="item in items.value">
<v-list-item>
<v-switch
v-model="reloadItems"
:value="item.value"
color="primary"
:label="item.title"
hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-col>
</v-row>
</v-card>
</v-dialog>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="3" v-for="i in reloadItems" :key="i">
<v-card class="rounded-lg" variant="outlined" height="200px"
:title="menuItems.flatMap(cat => cat.value).find(m => m.value == i)?.title">
<v-card-text style="padding: 0 16px;">
<Gauge :tilesData="tilesData" :type="i" v-if="i.charAt(0) == 'g'" />
<History :tilesData="tilesData" :type="i" v-if="i.charAt(0) == 'h'" />
<template v-if="i == 'i-sys'">
<v-row>
<v-col cols="3">{{ $t('main.info.host') }}</v-col>
<v-col cols="9" style="text-wrap: nowrap; overflow: hidden">{{ tilesData.sys?.hostName }}</v-col>
<v-col cols="3">{{ $t('main.info.cpu') }}</v-col>
<v-col cols="9">
<v-chip density="compact" variant="flat">
<v-tooltip activator="parent" location="top" style="direction: ltr;">
{{ tilesData.sys?.cpuType }}
</v-tooltip>
{{ tilesData.sys?.cpuCount }} {{ $t('main.info.core') }}
</v-chip>
</v-col>
<v-col cols="3">IP</v-col>
<v-col cols="9">
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sys?.ipv4?.length>0">
<v-tooltip activator="parent" location="top" style="direction: ltr;">
<span v-html="tilesData.sys?.ipv4?.join('<br />')"></span>
</v-tooltip>
IPv4
</v-chip>
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sys?.ipv6?.length>0">
<v-tooltip activator="parent" location="top" style="direction: ltr;">
<span v-html="tilesData.sys?.ipv6?.join('<br />')"></span>
</v-tooltip>
IPv6
</v-chip>
</v-col>
<v-col cols="3">S-UI</v-col>
<v-col cols="9">
<v-chip density="compact" color="primary" variant="flat">
<v-tooltip activator="parent" location="top">
{{ $t('main.info.threads') }}: {{ tilesData.sys?.appThreads }}<br />
{{ $t('main.info.memory') }}: {{ HumanReadable.sizeFormat(tilesData.sys?.appMem) }}
</v-tooltip>
v{{ tilesData.sys?.appVersion }}
</v-chip>
</v-col>
<v-col cols="3">{{ $t('main.info.uptime') }}</v-col>
<v-col cols="9">{{ HumanReadable.formatSecond(tilesData.uptime) }}</v-col>
</v-row>
</template>
<template v-if="i == 'i-sbd'">
<v-row>
<v-col cols="4">{{ $t('main.info.running') }}</v-col>
<v-col cols="8">
<v-chip density="compact" color="success" variant="flat" v-if="tilesData.sbd?.running">{{ $t('yes') }}</v-chip>
<v-chip density="compact" color="error" variant="flat" v-else>{{ $t('no') }}</v-chip>
</v-col>
<v-col cols="4">{{ $t('main.info.memory') }}</v-col>
<v-col cols="8">
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sbd?.stats?.Alloc">
{{ HumanReadable.sizeFormat(tilesData.sbd?.stats?.Alloc) }}
</v-chip>
</v-col>
<v-col cols="4">{{ $t('main.info.threads') }}</v-col>
<v-col cols="8">
<v-chip density="compact" color="primary" variant="flat" v-if="tilesData.sbd?.stats?.NumGoroutine">
{{ tilesData.sbd?.stats?.NumGoroutine }}
</v-chip>
</v-col>
<v-col cols="4">{{ $t('main.info.uptime') }}</v-col>
<v-col cols="8">{{ HumanReadable.formatSecond(tilesData.sbd?.stats?.Uptime) }}</v-col>
<v-col cols="4">{{ $t('online') }}</v-col>
<v-col cols="8">
<template v-if="tilesData.sbd?.running">
<v-chip density="compact" color="primary" variant="flat" v-if="Data().onlines.user">
<v-tooltip activator="parent" location="top" :text="$t('pages.clients')" />
{{ Data().onlines.user?.length }}
</v-chip>
<v-chip density="compact" color="success" variant="flat" v-if="Data().onlines.inbound">
<v-tooltip activator="parent" location="top" :text="$t('pages.inbounds')" />
{{ Data().onlines.inbound?.length }}
</v-chip>
<v-chip density="compact" color="info" variant="flat" v-if="Data().onlines.outbound">
<v-tooltip activator="parent" location="top" :text="$t('pages.outbounds')" />
{{ Data().onlines.outbound?.length }}
</v-chip>
</template>
</v-col>
</v-row>
</template>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-responsive>
</v-container>
</template>
<script lang="ts" setup>
import HttpUtils from '@/plugins/httputil'
import { HumanReadable } from '@/plugins/utils'
import Data from '@/store/modules/data'
import Gauge from '@/components/tiles/Gauge.vue'
import History from '@/components/tiles/History.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { i18n } from '@/locales'
const menu = ref(false)
const menuItems = [
{ title: i18n.global.t('main.gauges'), value: [
{ title: i18n.global.t('main.gauge.cpu'), value: "g-cpu" },
{ title: i18n.global.t('main.gauge.mem'), value: "g-mem" },
]
},
{ title: i18n.global.t('main.charts'), value: [
{ title: i18n.global.t('main.chart.cpu'), value: "h-cpu" },
{ title: i18n.global.t('main.chart.mem'), value: "h-mem" },
{ title: i18n.global.t('main.chart.net'), value: "h-net" },
{ title: i18n.global.t('main.chart.pnet'), value: "hp-net" },
]
},
{ title: i18n.global.t('main.infos'), value: [
{ title: i18n.global.t('main.info.sys'), value: "i-sys" },
{ title: i18n.global.t('main.info.sbd'), value: "i-sbd" },
]
},
]
const tilesData = ref(<any>{})
const reloadItems = computed({
get() { return Data().reloadItems },
set(v:string[]) {
if (Data().reloadItems.length == 0 && v.length>0) startTimer()
if (Data().reloadItems.length > 0 && v.length == 0) stopTimer()
Data().reloadItems = v
v.length>0 ? localStorage.setItem("reloadItems",v.join(',')) : localStorage.removeItem("reloadItems")
}
})
const reloadData = async () => {
const request = [...new Set(reloadItems.value.map(r => r.split('-')[1]))]
const data = await HttpUtils.get('/api/status',{ r: request.join(',')})
if (data.success) {
tilesData.value = data.obj
}
}
let intervalId: NodeJS.Timeout | null = null
const startTimer = () => {
intervalId = setInterval(() => {
reloadData()
}, 2000)
}
const stopTimer = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null
}
}
onMounted(() => {
if (Data().reloadItems.length != 0) {
reloadData()
startTimer()
}
})
onBeforeUnmount(() => {
stopTimer()
})
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<v-select
hide-details
:label="$t('network')"
:items="networks"
v-model="Network">
</v-select>
</template>
<script lang="ts">
export default {
props: ['inbound'],
data() {
return {
networks: [
{ title: "TCP/UDP", value: '' },
{ title: "TCP", value: 'tcp' },
{ title: "UDP", value: 'udp' },
],
}
},
computed: {
Network: {
get():string { return this.$props.inbound.network?? '' },
set(v:string) { this.$props.inbound.network = v != '' ? v : undefined }
}
}
}
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<v-card :subtitle="$t('in.transport')">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" :label="$t('transport.enable')" v-model="tpEnable" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="tpEnable">
<v-select
hide-details
width="100"
:label="$t('type')"
:items="Object.keys(trspTypes).map((key,index) => ({title: key, value: Object.values(trspTypes)[index]}))"
v-model="transportType">
</v-select>
</v-col>
</v-row>
<Http v-if="Transport.type == trspTypes.HTTP" :transport="Transport" />
<WebSocket v-if="Transport.type == trspTypes.WebSocket" :transport="Transport" />
<GRPC v-if="Transport.type == trspTypes.gRPC" :transport="Transport" />
<HttpUpgrade v-if="Transport.type == trspTypes.HTTPUpgrade" :transport="Transport" />
</v-card>
</template>
<script lang="ts">
import { TrspTypes, Transport } from '@/types/transport'
import Http from './transports/Http.vue'
import WebSocket from './transports/WebSocket.vue'
import GRPC from './transports/gRPC.vue'
import HttpUpgrade from './transports/HttpUpgrade.vue'
export default {
props: ['inbound'],
data() {
return {
trspTypes: TrspTypes
}
},
computed: {
Transport() {
return <Transport>this.$props.inbound.transport
},
tpEnable: {
get() { return Object.hasOwn(this.$props.inbound.transport, 'type') },
set(newValue: boolean) { this.$props.inbound.transport = newValue ? { type: 'http' } : {} }
},
transportType: {
get() { return this.Transport.type },
set(newValue: string) { this.$props.inbound.transport = { type: newValue } }
}
},
components: { Http, WebSocket, GRPC, HttpUpgrade }
}
</script>
+35
View File
@@ -0,0 +1,35 @@
<template>
<v-card subtitle="Clients">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch
v-model="hasUser"
@change="() => {inbound.users = hasUser? [] : undefined}"
color="primary"
:label="$t('in.clients')"
hide-details></v-switch>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
export default {
props: ['inbound', 'id'],
data() {
return {
hasUser: false,
}
},
computed: {
cardTitle() {
this.hasUser = Object.hasOwn(this.$props.inbound,'users')
return this.$props.inbound?.type.toUpperCase()
},
},
mounted() {
this.hasUser = Object.hasOwn(this.$props.inbound,'users')
}
}
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<v-snackbar
v-model="sb.showMsg"
location="top"
:color="snackbar.color"
:timeout="snackbar.timeout">
{{ snackbar.message }}
</v-snackbar>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import Message from '@/store/modules/message'
const sb = Message()
const snackbar = ref(sb.snackbar)
</script>
@@ -0,0 +1,43 @@
<template>
<v-card subtitle="Direct">
<v-row>
<v-col cols="12" sm="6" md="4">
<Network :inbound="inbound" />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Override Address"
hide-details
v-model="inbound.override_address">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Override Port"
type="number"
min="0"
hide-details
v-model="override_port">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['inbound'],
data() {
return {}
},
computed: {
override_port: {
get() { return this.$props.inbound.override_port ? this.$props.inbound.override_port : ''; },
set(newValue: any) { this.$props.inbound.override_port = newValue.length == 0 || newValue == 0 ? undefined : parseInt(newValue); }
},
},
components: { Network }
}
</script>
@@ -0,0 +1,63 @@
<template>
<v-card subtitle="Hysteria">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Uplink Limit"
hide-details
type="number"
suffix="Mbps"
v-model.number="up_mbps">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Downlink Limit"
hide-details
type="number"
suffix="Mbps"
min="0"
v-model.number="down_mbps">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="obfs Password"
hide-details
v-model="inbound.obfs">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
export default {
props: ['inbound'],
data() {
return {
}
},
computed: {
down_mbps: {
get() { return this.$props.inbound.down_mbps ? this.$props.inbound.down_mbps : 0 },
set(newValue:any) {
if (newValue.length != 0 ){
this.$props.inbound.down_mbps = newValue
this.$props.inbound.down = "" + newValue + " Mbps"
} else {
this.$props.inbound.down_mbps = 0
this.$props.inbound.down = "0 Mbps"
}
}
},
up_mbps: {
get() { return this.$props.inbound.up_mbps ? this.$props.inbound.up_mbps : 0 },
set(newValue:number) { this.$props.inbound.up_mbps = newValue > 0 ? newValue : 0 }
},
},
}
</script>
@@ -0,0 +1,91 @@
<template>
<v-card subtitle="Hysteria2">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Masquerade"
hide-details
v-model="hysteria2.masquerade"></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch v-model="hysteria2.ignore_client_bandwidth" color="primary" label="Ignore Client Bandwidth" hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="!hysteria2.ignore_client_bandwidth">
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Uplink Limit"
hide-details
type="number"
suffix="Mbps"
v-model.number="up_mbps">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Downlink Limit"
hide-details
type="number"
suffix="Mbps"
min="0"
v-model.number="down_mbps">
</v-text-field>
</v-col>
</v-row>
<v-row v-if="hysteria2.obfs">
<v-col cols="12" sm="6" md="4">
<v-text-field
label="obfs Password"
hide-details
v-model="hysteria2.obfs.password">
</v-text-field>
</v-col>
</v-row>
<v-card-actions>
<v-spacer></v-spacer>
<v-menu v-model="menu" :close-on-content-click="false" location="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details>Options</v-btn>
</template>
<v-card>
<v-list>
<v-list-item>
<v-switch v-model="optionObfs" color="primary" label="Obfs" hide-details></v-switch>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import { Hysteria2, createInbound } from '@/types/inbounds'
export default {
props: ['inbound'],
data() {
return {
menu: false,
hysteria2: <Hysteria2> createInbound("hysteria2",{ "tag": "" }),
}
},
computed: {
down_mbps: {
get() { return this.hysteria2.down_mbps ? this.hysteria2.down_mbps : 0 },
set(newValue:any) { this.hysteria2.down_mbps = newValue.length == 0 ? undefined : this.hysteria2.down_mbps }
},
up_mbps: {
get() { return this.hysteria2.up_mbps ? this.hysteria2.up_mbps : 0 },
set(newValue:any) { this.hysteria2.up_mbps = newValue.length == 0 ? undefined : this.hysteria2.up_mbps }
},
optionObfs: {
get(): boolean { return this.hysteria2.obfs != undefined },
set(v:boolean) { this.$props.inbound.obfs = v ? { type: "salamander", password: ""} : undefined }
}
},
mounted() {
this.hysteria2 = <Hysteria2> this.$props.inbound
}
}
</script>
@@ -0,0 +1,22 @@
<template>
<v-card subtitle="Naive">
<v-row>
<v-col cols="12" sm="6" md="4">
<Network :inbound="inbound" />
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['inbound'],
data() {
return {
}
},
components: { Network }
}
</script>
@@ -0,0 +1,153 @@
<template>
<v-card subtitle="ShadowTls">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
:items="[1,2,3]"
label="Version"
v-model="version">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4" v-if="inbound.password != undefined">
<v-text-field
label="Password"
hide-details
v-model="inbound.password">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Handshake Server"
hide-details
v-model="Inbound.handshake.server">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Server Port"
type="number"
min="0"
hide-details
v-model="server_port">
</v-text-field>
</v-col>
</v-row>
<Dial :dial="Inbound.handshake" />
<v-row v-if="Inbound.handshake_for_server_name != undefined">
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Add Hanshake Server"
hide-details
append-icon="mdi-plus"
@click:append="addHandshakeServer()"
v-model="handshake_server">
</v-text-field>
</v-col>
</v-row>
<v-card
v-for="(value, key) in Inbound.handshake_for_server_name"
border
density="compact"
style="margin: 5px;"
color="background">
<v-card-title>
<v-row>
<v-col>{{ key }}</v-col>
<v-spacer></v-spacer>
<v-col>
<v-btn @click="Inbound.handshake_for_server_name ? delete Inbound.handshake_for_server_name[key] : null"
icon="mdi-delete"
></v-btn>
</v-col>
</v-row>
</v-card-title>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Handshake Server"
hide-details
v-model="value.server">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Server Port"
type="number"
min="0"
hide-details
v-model="value.server_port">
</v-text-field>
</v-col>
</v-row>
<Dial :dial="value" />
</v-card>
</v-card>
</template>
<script lang="ts">
import { ShadowTLS } from '@/types/inbounds'
import Dial from '../Dial.vue'
export default {
props: ['inbound'],
data() {
return {
handshake_server: ''
}
},
methods: {
addHandshakeServer() {
this.inbound.handshake_for_server_name[this.handshake_server] = {}
// Clear the input field after adding the server
this.handshake_server = ''
}
},
mounted() {
this.version = this.Inbound.version
},
computed: {
version: {
get() { this.version = this.Inbound.version; return this.Inbound.version; },
set(newValue: any) {
switch (newValue) {
case 1:
this.Inbound.password = undefined
this.Inbound.users = undefined
this.Inbound.handshake_for_server_name = undefined
break;
case 2:
if (!this.Inbound.password) {
this.Inbound.password = ""
}
this.Inbound.users = undefined
if (!this.Inbound.handshake_for_server_name) {
this.Inbound.handshake_for_server_name = {}
}
break;
case 3:
this.Inbound.password = undefined
if (Object.hasOwn(this.Inbound, 'users')) {
this.Inbound.users = []
}
if (!this.Inbound.handshake_for_server_name) {
this.Inbound.handshake_for_server_name = {}
}
break;
}
this.Inbound.version = newValue;
}
},
Inbound(): ShadowTLS {
return <ShadowTLS>this.$props.inbound;
},
server_port: {
get() { return this.Inbound.handshake.server_port ? this.Inbound.handshake.server_port : 443; },
set(newValue: any) { this.Inbound.handshake.server_port = newValue.length == 0 || newValue == 0 ? 443 : parseInt(newValue); }
},
},
components: { Dial }
}
</script>
@@ -0,0 +1,44 @@
<template>
<v-card subtitle="Shadowsocks">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
label="Method"
:items="ssMethods"
v-model="inbound.method">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="inbound.password" label="Password" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<Network :inbound="inbound" />
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['inbound'],
data() {
return {
ssMethods: [
"none",
"aes-128-gcm",
"aes-192-gcm",
"aes-256-gcm",
"chacha20-ietf-poly1305",
"xchacha20-ietf-poly1305",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305"
]
}
},
components: { Network }
}
</script>
@@ -0,0 +1,22 @@
<template>
<v-card subtitle="TProxy">
<v-row>
<v-col cols="12" sm="6" md="4">
<Network :inbound="inbound" />
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import Network from '@/components/Network.vue'
export default {
props: ['inbound'],
data() {
return {
}
},
components: { Network }
}
</script>
@@ -0,0 +1,66 @@
<template>
<v-card subtitle="TUIC">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
label="Congestion Control"
:items="congestion_controls"
v-model="inbound.congestion_control">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" label="Zero-RTT Handshake" v-model="inbound.zero_rtt_handshake" hide-details></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Authentication Timeout"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="auth_timeout">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Heartbeat"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="heartbeat">
</v-text-field>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts">
import { TUIC } from '@/types/inbounds'
export default {
props: ['inbound'],
data() {
return {
congestion_controls: [
"cubic","new_reno", "bbr"
]
}
},
computed: {
Inbound(): TUIC {
return <TUIC> this.$props.inbound
},
auth_timeout: {
get() { return this.Inbound.auth_timeout ? parseInt(this.Inbound.auth_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.inbound.auth_timeout = newValue ? newValue + 's' : '' }
},
heartbeat: {
get() { return this.Inbound.heartbeat ? parseInt(this.Inbound.heartbeat.replace('s','')) : '' },
set(newValue:number) { this.$props.inbound.heartbeat = newValue ? newValue + 's' : '' }
}
}
}
</script>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts" setup>
import { HumanReadable } from '@/plugins/utils';
import { computed } from 'vue';
const props = defineProps({
tilesData: <any>{},
type: String
})
const data = computed(() => {
const d = props.tilesData
if (!d.mem && !d.cpu) return { percent: 0, text: '-' }
switch (props.type) {
case 'g-cpu':
return { percent: d.cpu, text: Math.ceil(d.cpu) + "%" }
case 'g-mem':
const curr = HumanReadable.sizeFormat(d.mem.current,0).split(' ')
const total = HumanReadable.sizeFormat(d.mem.total,0).split(' ')
if (curr[1] == total[1]) curr[1] = ''
return {
percent: Math.ceil(d.mem.current*100/d.mem.total),
text: curr[0] + "<sup>" + (curr[1]?? ' ') + "</sup>/" + total[0] + "<sup>" + (total[1]?? '') + "</sup>"
}
}
return { percent: 0, text: '-'}
})
const cssTransformRotateValue = computed(() => {
const percentageAsFraction = data.value.percent / 100
const halfPercentage = percentageAsFraction / 2
return `${halfPercentage}turn`
})
const gaugeColor = computed(() => {
if (data.value.percent > 90) return 'error'
if (data.value.percent > 70) return 'warning'
return 'primary'
})
</script>
<template>
<div class="gauge__outer">
<div class="gauge__inner">
<div
class="gauge__fill"
:style="{
transform: `rotate(${cssTransformRotateValue})`,
background: `rgb(var(--v-theme-${gaugeColor}))`
}">
</div>
<span class="gauge__cover" dir="ltr" v-html="data.text">
</span>
</div>
</div>
</template>
<style scoped>
.gauge__outer {
width: 100%;
max-width: 250px;
}
.gauge__inner {
width: 100%;
height: 0;
padding-bottom: 50%;
background: rgb(var(--v-theme-surface));
position: relative;
border-top-left-radius: 100% 200%;
border-top-right-radius: 100% 200%;
overflow: hidden;
}
.gauge__fill {
position: absolute;
top: 100%;
left: 0;
width: inherit;
height: 100%;
background: rgb(var(--v-theme-primary));
transform-origin: center top;
transform: rotate(0turn);
transition: transform 0.2s ease-out;
}
.gauge__cover {
width: 75%;
height: 150%;
background: rgb(var(--v-theme-background));
position: absolute;
top: 25%;
left: 50%;
transform: translateX(-50%);
border-radius: 50%;
/* Text */
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 25%;
box-sizing: border-box;
font-family: 'Lexend', sans-serif;
font-weight: bold;
font-size: 32px;
}
sup {
font-size: 16px;
}
</style>
+200
View File
@@ -0,0 +1,200 @@
<template>
<Line v-if="loaded" :data="data" :options="<any>options" />
</template>
<script lang="ts">
import { ref } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Filler,
} from 'chart.js'
import { HumanReadable } from '@/plugins/utils'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Filler
)
ChartJS.defaults.font.family = 'Vazirmatn'
export default {
components: {
Line
},
props: ['tilesData','type'],
data() {
return {
loaded: false,
labels: new Array(20).fill(''),
oldValues: <any>{},
options1: {
animation: false,
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
tooltip: {
enabled: false
},
legend: {
display: false,
}
},
scales: {
y: {
min: 0,
max: 100,
grid: {
color: () => { return this.$vuetify.theme.current.colors.secondary },
},
beginAtZero: true,
ticks: {
beginAtZero: true,
steps: 10,
stepValue: 5,
max: 100
}
}
}
},
optionsNet: {
animation: false,
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
tooltip: {
enabled: false
},
legend: {
display: false,
}
},
scales: {
y: {
grid: {
color: () => { return this.$vuetify.theme.current.colors.secondary },
},
beginAtZero: true,
ticks: {
callback: (label:any, index: number) => { return parseInt(label).toString() },
count: 10
}
}
}
},
data: ref(<any>{})
}
},
computed: {
options() {
switch (this.$props.type){
case "h-net":
this.optionsNet.scales.y.ticks.callback = (label:any, index: number) => {
return label == 0 ? "0" : HumanReadable.sizeFormat(label,0)
}
return this.optionsNet
case "hp-net":
this.optionsNet.scales.y.ticks.callback = (label:any, index: number) => {
return label == 0 ? "0" : HumanReadable.packetFormat(label,0)
}
return this.optionsNet
}
return this.options1
}
},
methods: {
updateData1(value1: number) {
const newData = <number[]>[]
if (this.data.datasets){
newData.push(...this.data.datasets[0].data,value1)
}
if (newData.length>20) newData.shift()
this.data = {
labels: this.labels,
datasets: [
{
label: '',
backgroundColor: 'rgba(255, 165, 0, 0.2)',
borderColor: 'rgba(255, 165, 0,0.8)',
fill: true,
data: newData
}
],
}
this.loaded = true
},
updateData2(value1: number, value2:number) {
const newData1 = <number[]>[]
const newData2 = <number[]>[]
if (this.data.datasets){
newData1.push(...this.data.datasets[0].data,value1)
newData2.push(...this.data.datasets[1].data,value2)
}
if (newData1.length>20) newData1.shift()
if (newData2.length>20) newData2.shift()
this.data = {
labels: this.labels,
datasets: [
{
label: '',
backgroundColor: 'rgba(255, 165, 0, 0.2)',
borderColor: 'rgba(255, 165, 0,0.8)',
fill: true,
data: newData1
},
{
label: '',
backgroundColor: 'rgba(0, 128, 0, 0.1)',
borderColor: 'rgba(0, 128, 0,0.8)',
fill: true,
data: newData2
}
],
}
this.loaded = true
}
},
watch: {
tilesData(v:any) {
switch (this.$props.type) {
case 'h-cpu':
this.updateData1(v.cpu)
break
case 'h-mem':
this.updateData1(v.mem.current*100/v.mem.total)
break
case 'h-net':
if (this.oldValues.sent) {
const downSpeed = (v.net.recv-this.oldValues.recv)/2 // Each 2 sec
const upSpeed = (v.net.sent-this.oldValues.sent)/2 // Each 2 sec
this.updateData2(upSpeed,downSpeed)
}
this.oldValues = v.net
break
case 'hp-net':
if (this.oldValues.psent) {
const downSpeed = (v.net.precv-this.oldValues.precv)/2 // Each 2 sec
const upSpeed = (v.net.psent-this.oldValues.psent)/2 // Each 2 sec
this.updateData2(upSpeed,downSpeed)
}
this.oldValues = v.net
break
}
}
}
}
</script>
@@ -0,0 +1,75 @@
<template>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.hosts')"
hide-details
v-model="hosts">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.path')"
hide-details
v-model="transport.path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Method"
hide-details
v-model="transport.method">
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Idle Timeout"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="idle_timeout">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Ping Timeout"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="ping_timeout">
</v-text-field>
</v-col>
</v-row>
</template>
<script lang="ts">
import { HTTP } from '../../types/transport'
export default {
props: ['transport'],
data() {
return {
}
},
computed: {
Http(): HTTP {
return <HTTP> this.$props.transport?? {}
},
hosts: {
get() { return this.Http.host ? this.Http.host.join(',') : '' },
set(newValue:string) { this.$props.transport.host = newValue.length>0 ? newValue.split(',') : [] }
},
idle_timeout: {
get() { return this.Http.idle_timeout ? parseInt(this.Http.idle_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.transport.idle_timeout = newValue ? newValue + 's' : '' }
},
ping_timeout: {
get() { return this.Http.ping_timeout ? parseInt(this.Http.ping_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.transport.ping_timeout = newValue ? newValue + 's' : '' }
}
}
}
</script>
@@ -0,0 +1,28 @@
<template>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.hosts')"
hide-details
v-model="transport.host">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.path')"
hide-details
v-model="transport.path">
</v-text-field>
</v-col>
</v-row>
</template>
<script lang="ts">
export default {
props: ['transport'],
data() {
return {
}
}
}
</script>
@@ -0,0 +1,51 @@
<template>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
:label="$t('transport.path')"
hide-details
v-model="transport.path">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Max Early Data"
hide-details
type="number"
min="0"
v-model.number="max_early_data">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Early Data Header Name"
hide-details
v-model="transport.early_data_header_name">
</v-text-field>
</v-col>
</v-row>
</template>
<script lang="ts">
import { WebSocket } from '../../types/transport'
export default {
props: ['transport'],
data() {
return {
}
},
computed: {
WS(): WebSocket {
return <WebSocket> this.$props.transport
},
max_early_data: {
get() { return this.WS.max_early_data ? this.WS.max_early_data : '' },
set(newValue:number) { this.$props.transport.max_early_data = newValue != 0 ? newValue : undefined }
},
},
mounted() {
this.WS.early_data_header_name = 'Sec-WebSocket-Protocol'
this.WS.path = '/'
}
}
</script>
@@ -0,0 +1,65 @@
<template>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Service Name"
hide-details
v-model="transport.service_name">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch
color="primary"
v-model="transport.permit_without_stream"
label="Permit Without Stream"
hide-details>
</v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Idle Timeout"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="idle_timeout">
</v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
label="Ping Timeout"
hide-details
type="number"
suffix="s"
min="1"
v-model.number="ping_timeout">
</v-text-field>
</v-col>
</v-row>
</template>
<script lang="ts">
import { gRPC } from '../../types/transport'
export default {
props: ['transport'],
data() {
return {
}
},
computed: {
GRPC(): gRPC {
return <gRPC> this.$props.transport?? {}
},
idle_timeout: {
get() { return this.GRPC.idle_timeout ? parseInt(this.GRPC.idle_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.transport.idle_timeout = newValue ? newValue + 's' : '' }
},
ping_timeout: {
get() { return this.GRPC.ping_timeout ? parseInt(this.GRPC.ping_timeout.replace('s','')) : '' },
set(newValue:number) { this.$props.transport.ping_timeout = newValue ? newValue + 's' : '' }
}
}
}
</script>
+44
View File
@@ -0,0 +1,44 @@
<template>
<v-app-bar :elevation="5">
<v-icon v-if="isMobile" icon="mdi-menu" @click="$emit('toggleDrawer')" />
<v-app-bar-title :text="$t(<string>$router.currentRoute.value.name)" class="align-center text-center " />
<v-btn prepend-icon="mdi-content-save" v-if="stateChange" :text="$t('actions.save')" @click="saveChanges"></v-btn>
<v-icon icon="mdi-theme-light-dark" @click="toggleTheme()" style="margin: 0 10px;"></v-icon>
</v-app-bar>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref,watch } from "vue"
import { useTheme } from "vuetify"
import { FindDiff } from "@/plugins/utils"
import Data from "@/store/modules/data"
defineProps(['isMobile'])
const theme = useTheme()
const darkMode = ref(localStorage.getItem('theme') == "dark")
const store = Data()
const toggleTheme = () => {
darkMode.value = !darkMode.value
theme.global.name.value = darkMode.value ? "dark" : "light"
localStorage.setItem('theme', theme.global.name.value)
}
const saveChanges = () => {
store.pushData()
}
const oldData = computed((): any => {
return {config: store.oldData.config, clients: store.oldData.clients}
})
const newData = computed((): any => {
return {config: store.config, clients: store.clients}
})
const stateChange = computed((): any => {
return !FindDiff.deepCompare(newData.value,oldData.value)
})
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<v-app style="overflow: auto;">
<drawer :isMobile="isMobile" :displayDrawer="displayDrawer" @toggleDrawer="toggleDrawer" />
<default-bar :isMobile="isMobile" @toggleDrawer="toggleDrawer" />
<default-view />
</v-app>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import DefaultBar from './AppBar.vue'
import Drawer from './Drawer.vue'
import DefaultView from './View.vue'
import { useDisplay } from 'vuetify'
const { smAndDown } = useDisplay()
const displayDrawer = ref(false)
const toggleDrawer = () => {
displayDrawer.value = !displayDrawer.value
}
const isMobile = computed( ():boolean =>{
displayDrawer.value = !smAndDown.value
return smAndDown.value
})
</script>
+67
View File
@@ -0,0 +1,67 @@
<template>
<v-navigation-drawer
v-model="showDrawer"
:temporary="isMobile"
:expand-on-hover="!isMobile"
:rail="!isMobile"
:permanent="!isMobile"
@click="isMobile ? $emit('toggleDrawer') : null"
>
<v-list-item
height="63"
prepend-avatar="@/assets/logo.svg"
title="S-UI"
>
<template v-slot:append v-if="isMobile">
<v-icon icon="mdi-close" />
</template>
</v-list-item>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item link
v-for="item in menu"
:key="item.title"
:to="item.path"
:active="router.currentRoute.value.path == item.path">
<template v-slot:prepend>
<v-icon :icon="item.icon"></v-icon>
</template>
<v-list-item-title v-text="$t(item.title)"></v-list-item-title>
</v-list-item>
</v-list>
<template v-slot:append>
<v-list-item prepend-icon="mdi-logout" :title="$t('menu.logout')" @click="logout"></v-list-item>
</template>
</v-navigation-drawer>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import router from '@/router'
import HttpUtil from '@/plugins/httputil'
const props = defineProps(['isMobile','displayDrawer'])
const showDrawer = computed((): boolean => {
return props.displayDrawer
})
const menu = [
{ title: 'pages.home', icon: 'mdi-home', path: '/' },
{ title: 'pages.inbounds', icon: 'mdi-cloud-download', path: '/inbounds' },
{ title: 'pages.clients', icon: 'mdi-account-multiple', path: '/clients' },
{ title: 'pages.outbounds', icon: 'mdi-cloud-upload', path: '/outbounds' },
{ title: 'pages.rules', icon: 'mdi-routes', path: '/rules' },
{ title: 'pages.basics', icon: 'mdi-application-cog', path: '/basics' },
{ title: 'pages.settings', icon: 'mdi-cog', path: '/settings' },
]
const logout = async () => {
const response = await HttpUtil.get('/api/logout')
if(response.success){
router.push('/login')
}
}
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<v-main>
<router-view />
</v-main>
</template>
<script lang="ts" setup>
</script>
<style>
.v-main {
margin: 10px;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.client') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px;">
<v-container style="padding: 0;">
<v-tabs
v-model="tab"
align-tabs="center"
>
<v-tab value="t1">{{ $t('client.basics') }}</v-tab>
<v-tab value="t2">{{ $t('client.config') }}</v-tab>
<v-tab value="t3">{{ $t('client.links') }}</v-tab>
</v-tabs>
<v-window v-model="tab">
<v-window-item value="t1">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="client.enable" :label="$t('enable')" hide-details></v-switch>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="client.name" :label="$t('client.name')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model.number="Volume" type="number" min="0" :label="$t('stats.volume')" suffix="GiB" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<DatePick :expiry="expDate" @submit="setDate" />
</v-col>
</v-row>
<v-row>
<v-col>
<v-combobox
v-model="clientInbounds"
:items="inboundTags"
:label="$t('client.inboundTags')"
multiple
chips
hide-details
></v-combobox>
</v-col>
</v-row>
<v-row>
<v-col cols="auto">
<v-switch v-model="clientStats" color="primary" :label="$t('stats.enable')" hide-details></v-switch>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="t2">
<v-row v-for="(value, key) in clientConfig" :key="key">
<v-col cols="12" md="3" align="end" align-self="center">
{{ key }}
</v-col>
<v-col>
<v-text-field
v-if="value.password != undefined"
label="Password"
v-model="value.password"
hide-details>
</v-text-field>
<v-text-field
v-if="value.uuid != undefined"
label="UUID"
v-model="value.uuid"
hide-details>
</v-text-field>
<v-text-field
v-if="value.flow != undefined"
label="Flow"
v-model="value.flow"
hide-details>
</v-text-field>
<v-text-field
v-if="value.auth_str != undefined"
label="Auth"
v-model="value.auth_str"
hide-details>
</v-text-field>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="t3">
<v-row v-for="(lnk, index) in links">
<v-col cols="auto">{{ index + 1 }}</v-col>
<v-col style="direction: ltr; overflow-y: hidden;">{{ lnk.uri }}</v-col>
</v-row>
<v-row>
<v-col>
<v-btn color="primary" @click="extLinks.push({ type: 'external', uri: ''})">{{ $t('actions.add') }} {{ $t('client.external') }}</v-btn>
</v-col>
</v-row>
<v-row v-for="(lnk, index) in extLinks">
<v-col>
<v-text-field
dir="ltr"
:label="$t('client.external') + ' ' + (index+1)"
append-icon="mdi-delete"
@click:append="extLinks.splice(index,1)"
placeholder="<protocol>://<data>"
v-model="lnk.uri" />
</v-col>
</v-row>
<v-row>
<v-col>
<v-btn color="primary" @click="subLinks.push({ type: 'sub', uri: ''})">{{ $t('actions.add') }} {{ $t('client.sub') }}</v-btn>
</v-col>
</v-row>
<v-row v-for="(lnk, index) in subLinks">
<v-col>
<v-text-field
dir="ltr"
:label="$t('client.sub') + ' ' + (index+1)"
append-icon="mdi-delete"
@click:append="subLinks.splice(index,1)"
placeholder="http[s]://<domain>[:]<port>/<path>"
v-model="lnk.uri" />
</v-col>
</v-row>
</v-window-item>
</v-window>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="blue-darken-1"
variant="outlined"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="blue-darken-1"
variant="tonal"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { Link } from '@/plugins/link'
import { createClient, randomConfigs, updateConfigs } from '@/types/clients'
import DatePick from '@/components/DateTime.vue'
export default {
props: ['visible', 'data', 'index', 'inboundTags', 'stats'],
emits: ['close', 'save'],
data() {
return {
client: createClient(),
title: "add",
clientStats: false,
tab: "t1",
clientConfig: <any>[],
links: <Link[]>[],
extLinks: <Link[]>[],
subLinks: <Link[]>[],
}
},
methods: {
updateData() {
if (this.$props.index != -1) {
const newData = JSON.parse(this.$props.data)
this.client = createClient(newData)
this.title = "edit"
this.clientConfig = JSON.parse(this.client.config)
}
else {
this.client = createClient()
this.title = "add"
this.clientConfig = randomConfigs('client')
}
this.clientStats = this.$props.stats
const allLinks = <Link[]>JSON.parse(this.client.links)
this.links = allLinks.filter(l => l.type == 'local')
this.extLinks = allLinks.filter(l => l.type == 'external')
this.subLinks = allLinks.filter(l => l.type == 'sub')
this.tab = "t1"
},
closeModal() {
this.updateData() // reset
this.$emit('close')
},
saveChanges() {
this.client.config = updateConfigs(JSON.stringify(this.clientConfig), this.client.name)
this.client.links = JSON.stringify([
...this.links,
...this.extLinks.filter(l => l.uri != ''),
...this.subLinks.filter(l => l.uri != '')])
this.$emit('save', this.client, this.clientStats)
},
setDate(newDate:number){
this.client.expiry = newDate
}
},
computed: {
clientInbounds: {
get() { return this.client.inbounds == "" ? [] : this.client.inbounds.split(',') },
set(newValue:string[]) { this.client.inbounds = newValue.length == 0 ? "" : newValue.join(',') }
},
expDate: {
get() { return this.client.expiry},
set(v:any) { this.client.expiry = v }
},
Volume: {
get() { return this.client.volume == 0 ? 0 : (this.client.volume / (1024 ** 3)) },
set(v:number) { this.client.volume = v > 0 ? v*(1024 ** 3) : 0 }
}
},
watch: {
visible(newValue) { if (newValue) {
this.updateData()
}
},
},
components: { DatePick },
}
</script>
+131
View File
@@ -0,0 +1,131 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
{{ $t('actions.' + title) + " " + $t('objects.inbound') }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
width="100"
:label="$t('type')"
:items="Object.keys(inTypes).map((key,index) => ({title: key, value: Object.values(inTypes)[index]}))"
v-model="inbound.type"
@update:modelValue="changeType">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="inbound.tag" :label="$t('in.tag')" hide-details></v-text-field>
</v-col>
</v-row>
<Listen :inbound="inbound" />
<Direct v-if="inbound.type == inTypes.Direct" :inbound="inbound" />
<Shadowsocks v-if="inbound.type == inTypes.Shadowsocks" :inbound="inbound" />
<Hysteria v-if="inbound.type == inTypes.Hysteria" :inbound="inbound" />
<Hysteria2 v-if="inbound.type == inTypes.Hysteria2" :inbound="inbound" />
<Naive v-if="inbound.type == inTypes.Naive" :inbound="inbound" />
<ShadowTls v-if="inbound.type == inTypes.ShadowTLS" :inbound="inbound" />
<Tuic v-if="inbound.type == inTypes.TUIC" :inbound="inbound" />
<TProxy v-if="inbound.type == inTypes.TProxy" :inbound="inbound" />
<Transport v-if="Object.hasOwn(inbound,'transport')" :inbound="inbound" />
<Users v-if="HasOptionalUser.includes(inbound.type)" :inbound="inbound" :id="id" />
<InTls v-if="Object.hasOwn(inbound,'tls')" :inbound="inbound" />
<InMulitiplex v-if="Object.hasOwn(inbound,'multiplex')" :inbound="inbound" />
<v-switch v-model="inboundStats" color="primary" :label="$t('stats.enable')" hide-details></v-switch>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="blue-darken-1"
variant="text"
@click="closeModal"
>
{{ $t('actions.close') }}
</v-btn>
<v-btn
color="blue-darken-1"
variant="text"
@click="saveChanges"
>
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { InTypes, createInbound } from '@/types/inbounds'
import Listen from '@/components/Listen.vue'
import Direct from '@/components/protocols/Direct.vue'
import Users from '@/components/Users.vue'
import Shadowsocks from '@/components/protocols/Shadowsocks.vue'
import Hysteria from '@/components/protocols/Hysteria.vue'
import Hysteria2 from '@/components/protocols/Hysteria2.vue'
import Naive from '@/components/protocols/Naive.vue'
import ShadowTls from '@/components/protocols/ShadowTls.vue'
import Tuic from '@/components/protocols/Tuic.vue'
import InTls from '@/components/InTLS.vue'
import TProxy from '@/components/protocols/TProxy.vue'
import RandomUtil from '@/plugins/randomUtil'
import InMulitiplex from '@/components/InMulitiplex.vue'
import Transport from '@/components/Transport.vue'
export default {
props: ['visible', 'data', 'id', 'stats'],
emits: ['close', 'save'],
data() {
return {
inbound: createInbound("direct",{ "tag": "" }),
title: "add",
inTypes: InTypes,
inboundStats: false,
HasOptionalUser: [InTypes.Mixed,InTypes.SOCKS,InTypes.HTTP,InTypes.Shadowsocks],
}
},
methods: {
updateData() {
if (this.$props.id != -1) {
const newData = JSON.parse(this.$props.data)
this.inbound = createInbound(newData.type, newData)
this.title = "edit"
}
else {
const port = RandomUtil.randomIntRange(10000, 60000)
this.inbound = createInbound("mixed",{ tag: "in-"+port ,listen: "::", listen_port: port })
this.title = "add"
}
this.inboundStats = this.$props.stats
},
changeType() {
const prevConfig = { tag: this.inbound.tag ,listen: this.inbound.listen, listen_port: this.inbound.listen_port }
this.inbound = createInbound(this.inbound.type, prevConfig)
},
closeModal() {
this.updateData() // reset
this.$emit('close')
},
saveChanges() {
this.$emit('save', this.inbound, this.inboundStats)
},
},
watch: {
visible(newValue) {
if (newValue) {
this.updateData()
}
},
},
components: { Listen, InTls, Hysteria2, Naive, Direct, Shadowsocks, Users, Hysteria, ShadowTls, TProxy, InMulitiplex, Tuic, Transport }
}
</script>
<style>
.v-card-subtitle {
text-align: center;
border-bottom: 1px solid gray;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="400">
<v-card class="rounded-lg">
<v-card-title>
<v-row>
<v-col>QrCode</v-col>
<v-spacer></v-spacer>
<v-col cols="1"><v-icon icon="mdi-close-box" @click="$emit('close')" ></v-icon></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col style="text-align: center;" @click="copyToClipboard(clientSub)">
<v-chip>{{ $t('setting.sub') }}</v-chip>
<QrcodeVue :value="clientSub" :size="300" :margin="1" style="border-radius: 1rem;" />
</v-col>
</v-row>
<v-row v-for="l in clientLinks">
<v-col style="text-align: center;" @click="copyToClipboard(l.uri)">
<v-chip>{{ l.remark }}</v-chip><br />
<QrcodeVue :value="l.uri" :size="300" :margin="1" style="border-radius: 1rem;" />
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import QrcodeVue from 'qrcode.vue'
import Data from '@/store/modules/data'
import Clipboard from 'clipboard'
import Message from '@/store/modules/message'
import { i18n } from '@/locales'
export default {
props: ['index'],
data() {
return {
msg: Message(),
}
},
methods: {
copyToClipboard(txt:string) {
const clipboard = new Clipboard('.clipboard-btn', {
text: () => txt
});
clipboard.on('success', () => {
clipboard.destroy()
this.msg.showMessage(i18n.global.t('copyToClipboard') + " : " + i18n.global.t('success'),'success',5000)
})
clipboard.on('error', () => {
clipboard.destroy()
this.msg.showMessage(i18n.global.t('copyToClipboard') + " : " + i18n.global.t('failed'),'error',5000)
})
// Perform click on hidden button to trigger copy
const hiddenButton = document.createElement('button');
hiddenButton.className = 'clipboard-btn';
document.body.appendChild(hiddenButton);
hiddenButton.click();
document.body.removeChild(hiddenButton);
}
},
computed: {
clients() { return Data().clients },
client() {
if ( typeof this.$props.index != 'number' ) return <any>{}
return this.clients[this.$props.index]
},
clientSub() {
return Data().subURI + this.client.name
},
clientLinks() {
return JSON.parse(this.client.links?? "[]")
}
},
components: { QrcodeVue }
}
</script>
+186
View File
@@ -0,0 +1,186 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="800">
<v-card class="rounded-lg" :loading="loading" color="background">
<v-card-title>
<v-row>
<v-col>
{{ $t('stats.graphTitle') + " - " + $t('objects.' + resource) + " : " + tag }}
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto"><v-icon icon="mdi-close" @click="$emit('close')"></v-icon></v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px;">
<v-container id="container">
<v-alert :text="$t('noData')" type="warning" variant="outlined" v-if="alert"></v-alert>
<Line v-if="loaded" :data="usage" :options="<any>options" />
</v-container>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { i18n } from '@/locales'
import HttpUtils from '@/plugins/httputil'
import { HumanReadable } from '@/plugins/utils'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js'
import { ref } from 'vue'
import { Line } from 'vue-chartjs'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
ChartJS.defaults.font.family = 'Vazirmatn'
export default {
components: {
Line
},
props: ['visible','resource','tag'],
data() {
return {
loading: false,
loaded: false,
alert: false,
intervalId: <any>0,
options: {
responsive: true,
maintainAspectRatio: true,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
tooltip: {
callbacks: {
text: (ctx:any) => {
const {axis = 'xy', intersect, mode} = ctx.chart.options.interaction;
return 'Mode: ' + mode + ', axis: ' + axis + ', intersect: ' + intersect;
},
footer: (items:any[]) => {
return HumanReadable.sizeFormat(items.reduce((acc, c) => acc + c.raw, 0))
}
}
}
},
scales: {
y: {
grid: {
color: () => { return this.$vuetify.theme.current.colors.secondary },
},
beginAtZero: true,
ticks: {
callback: function(label:any, index: number) {
return label == 0 ? 0 : HumanReadable.sizeFormat(label,0)
},
count: 10
}
}
}
},
usage: ref(<any>{}),
}
},
methods: {
async loadData(limit: number) {
this.loading = true
const data = await HttpUtils.get('/api/stats', { resource: this.resource, tag: this.tag, limit: limit })
if (data.success && data.obj) {
const obj = <any[]>data.obj
const l = String(i18n.global.locale) == 'fa' ? "fa-IR" : "en-US"
const oneStep = limit * 3600 * 1000 / 360 // Each 10 sec
const now = new Date().getTime()
const steps = <number[]>[]
for (let i = 360; i >= 0; i--) {
steps.push(now - (oneStep * i))
}
const labels = <string[]>[]
const uplinkData = <number[]>[]
const downlinkData = <number[]>[]
for (let i = 1; i<360; i++) {
labels.push(this.genLable(steps[i],l))
let upSum:number
let downSum:number
const upTraffics = obj.filter(o => o.direction && o.dateTime*1000 < steps[i] && o.dateTime*1000 > steps[i-1]).map((o:any) => o.traffic)
upSum = upTraffics.length>0 ? upTraffics.reduce(u => u) : null
const downTraffics = obj.filter(o => !o.direction && o.dateTime*1000 < steps[i] && o.dateTime*1000 > steps[i-1]).map((o:any) => o.traffic)
downSum = downTraffics.length>0 ? downTraffics.reduce(d => d) : null
uplinkData.push(upSum)
downlinkData.push(downSum)
}
this.usage = {
labels: labels,
datasets: [
{
label: i18n.global.t('stats.upload'),
backgroundColor: 'rgba(255, 165, 0, 0.4)',
borderColor: 'rgba(255, 165, 0)',
fill: true,
data: uplinkData
},
{
label: i18n.global.t('stats.download'),
backgroundColor: 'rgba(0, 128, 0, 0.2)',
borderColor: 'rgba(0, 128, 0)',
fill: true,
data: downlinkData
}
],
}
this.loaded = true
} else {
this.alert = true
}
this.loading = false
},
genLable(step:number, locale: string) {
return new Date(step).toLocaleString(locale,{
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
},
watch: {
visible(v) {
if (v) {
const limit = 1
this.loadData(limit)
this.intervalId = setInterval(() => {
this.loadData(limit)
}, 10000)
} else {
this.loaded = false
this.alert = false
this.usage.labels = []
if (this.usage.datasets) {
this.usage.datasets[0].data = []
this.usage.datasets[1].data = []
}
if (this.intervalId && this.intervalId != 0) {
clearInterval(this.intervalId)
}
}
}
}
}
</script>
+160
View File
@@ -0,0 +1,160 @@
export default {
message: "Welcome",
success: "success",
failed: "failed",
enable: "Enable",
disable: "Disable",
loading: "Loading...",
confirm: "Are you sure ?",
yes: "yes",
no: "no",
unlimited: "infinite",
remained: "Remained",
type: "Type",
submit: "Submit",
reset: "Reset",
now: "Now",
network: "Network",
copyToClipboard: "Copy to clipboard",
noData: "No data!",
online: "Online",
pages: {
login: "Login",
home: "Home",
inbounds: "Inbounds",
outbounds: "Outbounds",
clients: "Clients",
rules: "Rules",
basics: "Basics",
settings: "Settings",
},
main: {
tiles: "Tiles",
gauges: "Gauges",
charts: "Charts",
infos: "Information",
gauge: {
cpu: "CPU Gauge",
mem: "RAM Gauge",
},
chart: {
cpu: "CPU Monitor",
mem: "RAM Monitor",
net: "Network Bandwidth",
pnet: "Network Packets",
},
info: {
sys: "System Info",
sbd: "Sing-Box Info",
host: "Host",
cpu: "CPU",
core: "Core",
uptime: "Uptime",
threads: "Threads",
memory: "Memory",
running: "Running"
}
},
objects: {
inbound: "Inbound",
client: "Client",
outbound: "Outbound",
rule: "Rule",
user: "User",
},
actions: {
action: "Action",
add: "Add",
edit: "Edit",
del: "Delete",
save: "Save",
update: "Update",
close: "Close",
restartApp: "Restart App",
},
login: {
username: "Username",
unRules: "Username can not be empty",
password: "Password",
pwRules: "Password can not be empty",
},
menu: {
logout: "Logout",
},
setting: {
interface: "Interface",
sub: "Subscription",
addr: "Address",
port: "Port",
domain: "Domain",
sslKey: "SSL Key Path",
sslCert: "SSL Certificate Path",
sessionAge: "Session Maximum Age",
timeLoc: "Timezone Location",
subEncode: "Enable Encoding",
subInfo: "Enable Client Info",
path: "Default Path",
update: "Automatic Update Time",
subUri: "Subscription URI",
},
client: {
name: "Name",
inboundTags: "Inbound Tags",
basics: "Basics",
config: "Config",
links: "Links",
external: "External Link",
sub: "External Subscription",
},
in: {
tag: "Tag",
addr: "Address",
port: "Port",
sniffing: "Sniffing",
tls: "TLS",
clients: "Enable Clients",
multiplex: "Multiplex",
transport: "Transport",
},
transport: {
enable: "Enable Transport",
host: "Host",
hosts: "Hosts",
path: "Path",
},
tls : {
enable: "Enable TLS",
usePath: "Use Path",
useText: "Use Text",
certPath: "Certificate File Path",
keyPath: "Key File Path",
cert: "Certificate",
key: "Key",
},
stats: {
upload: "Upload",
download: "Download",
volume: "Volume",
usage: "Usage",
enable: "Enable Statistics",
graphTitle: "Traffic Chart",
B: "B",
KB: "KB",
MB: "MB",
GB: "GB",
TB: "TB",
PB: "PB",
p: "p",
Kp: "Kp",
Mp: "Mp",
Gb: "Gb",
},
date: {
expiry: "Expiry",
expired: "Expired",
d: "d",
h: "h",
m: "m",
s: "s",
}
}
+160
View File
@@ -0,0 +1,160 @@
export default {
message: "خوش آمدید",
success: "موفق",
failed: "خطا",
enable: "فعال",
disable: "غیرفعال",
loading: "در حال بارگذاری...",
confirm: "آیا مطمئن هستید ؟",
yes: "بله",
no: "خیر",
unlimited: "نامحدود",
remained: "باقیمانده",
type: "مدل",
submit: "تایید",
reset: "ریست",
now: "اکنون",
network: "شبکه",
copyToClipboard: "کپی در حافظه",
noData: "بدون داده!",
online: "آنلاین",
pages: {
login: "ورود",
home: "خانه",
inbounds: "ورودی‌ها",
outbounds: "خروجی‌ها",
clients: "کاربران",
rules: "قوانین",
basics: "ترازها",
settings: "پیکربندی",
},
main: {
tiles: "کاشی‌ها",
gauges: "سنجش‌ها",
charts: "نمودارها",
infos: "داده‌ها",
gauge: {
cpu: "سنجش پردازنده",
mem: "سنجش حافظه",
},
chart: {
cpu: "نمودار پردازنده",
mem: "نمودار حافظه",
net: "ترافیک شبکه",
pnet: "بسته‌های شبکه",
},
info: {
sys: "داده‌های سیستم",
sbd: "داده‌های سینگ‌باکس",
host: "نام",
cpu: "پردازنده",
core: "هسته",
uptime: "مدت‌",
threads: "نخ‌ها",
memory: "حافظه",
running: "اجرا"
}
},
objects: {
inbound: "ورودی‌",
client: "کاربر",
outbound: "خروجی‌",
rule: "قانون",
user: "کاربر",
},
actions: {
action: "فرمان",
add: "ایجاد",
edit: "ویرایش",
del: "حذف",
save: "ذخیره",
update: "بروزرسانی",
close: "بستن",
restartApp: "ریستارت پنل",
},
login: {
username: "نام کاربری",
unRules: "نام کاربری نمی‌تواند خالی باشد",
password: "کلمه عبور",
pwRules: "کلمه عبور نمی‌تواند خالی باشد",
},
menu: {
logout: "خروج",
},
setting: {
interface: "نما",
sub: "سابسکریپشن",
addr: "آدرس",
port: "پورت",
domain: "دامنه",
sslKey: "مسیر فایل کلید",
sslCert: "مسیر فایل گواهی",
sessionAge: "بیشینه زمان لاگین ماندن",
timeLoc: "منطقه زمانی",
subEncode: "رمزگذاری",
subInfo: "نمایش اطلاعات کاربر",
path: "مسیر پیشفرض",
update: "زمان بروزرسانی خودکار",
subUri: "آدرس نهایی سابسکریپشن",
},
client: {
name: "نام",
inboundTags: "برچسب‌های ورودی",
basics: "پایه",
config: "تنظیم",
links: "لینک‌ها",
external: "لینک‌ خارجی",
sub: "سابسکریپشن خارجی",
},
in: {
tag: "برچسب",
addr: "آدرس",
port: "پورت",
sniffing: "مبدل آدرس",
tls: "رمزنگاری",
clients: "فعال‌سازی کاربران",
multiplex: "تسهیم",
transport: "انتقال",
},
transport: {
enable: "فعال‌سازی انتقال",
host: "دامنه",
hosts: "دامنه‌ها",
path: "مسیر",
},
tls : {
enable: "فعالسازی رمزنگاری",
usePath: "مسیر فایل",
useText: "متن گواهی",
certPath: "مسیر فایل گواهی",
keyPath: "مسیر فایل کلید",
cert: "گواهی",
key: "کلید",
},
stats: {
upload: "آپلود",
download: "دانلود",
volume: "حجم",
usage: "استفاده",
enable: "فعال سازی کنترل ترافیک",
graphTitle: "نمودار ترافیک",
B: "ب",
KB: "ک‌ب",
MB: "م‌ب",
GB: "گ‌ب",
TB: "ت‌ب",
PB: "پ‌ب",
p: "پ",
Kp: "ک‌پ",
Mp: "م‌پ",
Gp: "گ‌پ",
},
date: {
expiry: "انقضا",
expired: "منقضی",
d: "ر",
h: "س",
m: "د",
s: "ث",
}
}
+19
View File
@@ -0,0 +1,19 @@
import { createI18n } from 'vue-i18n'
import en from './en'
import fa from './fa'
export const i18n = createI18n({
legacy: false,
locale: localStorage.getItem("locale") ?? 'en',
fallbackLocale: 'en',
messages: {
en,
fa,
},
})
export const languages = [
{ title: 'English', value: 'en' },
{ title: 'فارسی', value: 'fa' },
]
+38
View File
@@ -0,0 +1,38 @@
/**
* main.ts
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Composables
import { createApp, ref } from 'vue'
// Components
import App from './App.vue'
// Use router
import router from './router'
// Store
import store from './store'
// Plugins
import { registerPlugins } from '@/plugins'
// Locale
import { i18n } from '@/locales'
import Vue3PersianDatetimePicker from 'vue3-persian-datetime-picker'
const loading = ref(false)
const app = createApp(App)
app.provide('loading', loading)
registerPlugins(app)
app
.use(router)
.use(store)
.use(i18n)
.component('DatePicker', Vue3PersianDatetimePicker)
.mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import axios from 'axios'
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
axios.interceptors.request.use(
(config) => {
if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data'
}
return config
},
(error) => Promise.reject(error),
)
const api = axios.create()
export default api
+66
View File
@@ -0,0 +1,66 @@
import api from './api'
import { i18n } from '@/locales'
import Message from "@/store/modules/message"
export interface Msg {
success: boolean
msg: string
obj: any | null
}
function _handleMsg(msg: any): void {
const sb = Message()
if (!isMsg(msg)) {
return
}
if(msg.msg){
const message = msg.success ? i18n.global.t('success') + ": " + i18n.global.t('actions.' + msg.msg) : i18n.global.t('failed') + ": " + msg.msg
sb.showMessage(message, msg.success ? 'success' : 'error', 5000)
}
}
function _respToMsg(resp: any): Msg {
const data = resp.data
if (data == null) {
return { success: true, msg: "", obj: null }
} else if (isMsg(data)) {
if (data.hasOwnProperty('success')) {
return { success: data.success, msg: data.msg, obj: data.obj || null }
} else {
return data
}
} else {
return { success: false, msg: `unknown data: ${data}`, obj: null }
}
}
function isMsg(obj: any): obj is Msg {
return 'success' in obj && 'msg' in obj && 'obj' in obj
}
const HttpUtils = {
async get(url: string, data: object = {}, options: any[] = []): Promise<Msg> {
let msg: Msg
try {
const resp = await api.get(url, { params: data, ...options })
msg = _respToMsg(resp)
} catch (e: any) {
msg = { success: false, msg: e.toString(), obj: null }
}
_handleMsg(msg)
return msg
},
async post(url: string, data: object | null, options: any = undefined): Promise<Msg> {
let msg: Msg
try {
const resp = await api.post(url, data, options)
msg = _respToMsg(resp)
} catch (e: any) {
msg = { success: false, msg: e.toString(), obj: null }
}
_handleMsg(msg)
return msg
},
}
export default HttpUtils;
+10
View File
@@ -0,0 +1,10 @@
// Plugins
import vuetify from './vuetify'
// Types
import type { App } from 'vue'
export function registerPlugins (app: App) {
app
.use(vuetify)
}
+235
View File
@@ -0,0 +1,235 @@
import { Hysteria, Hysteria2, InTypes, Inbound, Naive, Shadowsocks, TUIC, Trojan, VLESS, VMess } from "@/types/inbounds"
import { HTTP, WebSocket, QUIC, gRPC, HTTPUpgrade, Transport, TrspTypes } from "@/types/transport";
export interface Link {
type: "local" | "external" | "sub"
remark?: string
uri: string
}
function utf8ToBase64(utf8String: string): string {
const encodedUtf8 = encodeURIComponent(utf8String).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)));
return btoa(encodedUtf8);
}
export namespace LinkUtil {
export function linkGenerator(user: string, inbound: Inbound): string {
const addr = location.hostname
switch(inbound.type){
case InTypes.Shadowsocks:
return shadowsocksLink(user,<Shadowsocks>inbound,addr)
case InTypes.Naive:
return naiveLink(user,<Naive>inbound,addr)
case InTypes.Hysteria:
return hysteriaLink(user,<Hysteria>inbound,addr)
case InTypes.Hysteria2:
return hysteria2Link(user,<Hysteria2>inbound,addr)
case InTypes.TUIC:
return tuicLink(user,<TUIC>inbound,addr)
case InTypes.VLESS:
return vlessLink(user,<VLESS>inbound,addr)
case InTypes.Trojan:
return trojanLink(user,<Trojan>inbound,addr)
case InTypes.VMess:
return vmessLink(user,<VMess>inbound,addr)
}
return ''
}
function shadowsocksLink(user: string, inbound: Shadowsocks, addr: string): string {
const userPass = inbound.users?.find(i => i.name == user)?.password
const password = [userPass]
if (inbound.method.startsWith('2022')) password.push(inbound.password)
const params = {
tfo: inbound.tcp_fast_open? 1 : null,
network: inbound.network?? null
}
const uri = new URL(`ss://${utf8ToBase64(inbound.method + ':' + password.join(':'))}@${addr}:${inbound.listen_port}`)
for (const [key, value] of Object.entries(params)){
if (value) {
uri.searchParams.set(key, value.toString())
}
}
uri.hash = encodeURIComponent(inbound.tag)
return uri.toString()
}
function hysteriaLink(user: string, inbound: Hysteria, addr: string): string {
const auth = inbound.users.find(i => i.name == user)?.auth_str
const params = {
upmbps: inbound.up_mbps?? null,
downmbps: inbound.down_mbps?? null,
auth: auth?? null,
peer: inbound.tls.server_name?? null,
alpn: inbound.tls.alpn?.join(',')?? null,
obfsParam: inbound.obfs?? null,
fastopen: inbound.tcp_fast_open? 1 : 0
}
const uri = new URL(`hysteria://${addr}:${inbound.listen_port}`)
for (const [key, value] of Object.entries(params)){
if (value) {
uri.searchParams.set(key, value.toString())
}
}
uri.hash = encodeURIComponent(inbound.tag)
return uri.toString()
}
function hysteria2Link(user: string, inbound: Hysteria2, addr: string): string {
const password = inbound.users.find(i => i.name == user)?.password
const params = {
upmbps: inbound.up_mbps?? null,
downmbps: inbound.down_mbps?? null,
sni: inbound.tls.server_name?? null,
alpn: inbound.tls.alpn?.join(',')?? null,
obfs: inbound.obfs?.type?? null,
'obfs-password': inbound.obfs?.password?? null,
fastopen: inbound.tcp_fast_open? 1 : 0
}
const uri = new URL(`hysteria2://${password}@${addr}:${inbound.listen_port}`)
for (const [key, value] of Object.entries(params)){
if (value) {
uri.searchParams.set(key, value.toString())
}
}
uri.hash = encodeURIComponent(inbound.tag)
return uri.toString()
}
function naiveLink(user: string, inbound: Naive, addr: string): string {
const password = inbound.users.find(i => i.username == user)?.password
const params = {
padding: 1,
peer: inbound.tls.server_name?? null,
alpn: inbound.tls.alpn?.join(',')?? null,
tfo: inbound.tcp_fast_open? 1 : 0
}
const uri = `http2://${utf8ToBase64(user + ":" + password + "@" + addr + ":" + inbound.listen_port)}`
const paramsArray = []
for (const [key, value] of Object.entries(params)){
if (value) {
paramsArray.push(`${key}=${encodeURIComponent(value.toString())}`)
}
}
return uri.toString() + "?" + paramsArray.join('&') + "#" + inbound.tag
}
function tuicLink(user: string, inbound: TUIC, addr: string): string {
const u = inbound.users.find(i => i.name == user)
const params = {
sni: inbound.tls.server_name?? null,
alpn: inbound.tls.alpn?.join(',')?? null,
congestion_control: inbound.congestion_control?? null
}
const uri = new URL(`tuic://${u?.uuid}:${u?.password}@${addr}:${inbound.listen_port}`)
for (const [key, value] of Object.entries(params)){
if (value) {
uri.searchParams.set(key, value.toString())
}
}
uri.hash = encodeURIComponent(inbound.tag)
return uri.toString()
}
function getTransportParams(t:Transport): any {
if (Object.keys(t).length == 0) return {}
const params = {
host: <string|null>'',
path: <string|null>'',
serviceName: <string|null>'',
}
switch (t.type){
case TrspTypes.HTTP:
const th = <HTTP>t
params.host = th.host?.join(',')?? null
params.path = th.path?? null
break
case TrspTypes.WebSocket:
const tw = <WebSocket>t
params.path = tw.path?? null
break
case TrspTypes.gRPC:
const tg = <gRPC>t
params.serviceName = tg.service_name?? null
break
case TrspTypes.HTTPUpgrade:
const tu = <HTTPUpgrade>t
params.host = tu.host?? null
params.path = tu.path?? null
break
}
return params
}
function vlessLink(user: string, inbound: VLESS, addr: string): string {
const u = inbound.users.find(i => i.name == user)
const transport = <Transport>inbound.transport
const tParams = getTransportParams(transport)
const params = {
type: transport?.type?? 'none',
security: inbound.tls?.enabled? 'tls' : null,
alpn: inbound.tls?.alpn?.join(',')?? null,
sni: inbound.tls?.server_name?? null,
flow: inbound.tls?.enabled ? u?.flow?? null : null
}
const uri = new URL(`vless://${u?.uuid}@${addr}:${inbound.listen_port}`)
for (const [key, value] of Object.entries({...params, ...tParams})){
if (value) {
uri.searchParams.set(key, value.toString())
}
}
uri.hash = encodeURIComponent(inbound.tag)
return uri.toString()
}
function trojanLink(user: string, inbound: Trojan, addr: string): string {
const u = inbound.users.find(i => i.name == user)
const transport = <Transport>inbound.transport
const tParams = getTransportParams(transport)
const params = {
type: transport?.type?? 'none',
security: inbound.tls?.enabled? 'tls' : null,
alpn: inbound.tls?.alpn?.join(',')?? null,
sni: inbound.tls?.server_name?? null,
}
const uri = new URL(`trojan://${u?.password}@${addr}:${inbound.listen_port}`)
for (const [key, value] of Object.entries({...params, ...tParams})){
if (value) {
uri.searchParams.set(key, value.toString())
}
}
uri.hash = encodeURIComponent(inbound.tag)
return uri.toString()
}
function vmessLink(user: string, inbound: VMess, addr: string): string {
const u = inbound.users.find(i => i.name == user)
const transport = <Transport>inbound.transport
const tParams = getTransportParams(transport)
if (transport.type == TrspTypes.gRPC) tParams.path = tParams.serviceName
const params = {
v: 2,
add: addr,
aid: u?.alterId,
host: tParams.host,
id: u?.uuid,
net: transport.type,
path: tParams.path,
port: inbound.listen_port,
ps: inbound.tag,
sni: inbound.tls.server_name?? '',
tls: Object.keys(inbound.tls).length>0? 'tls' : 'none'
}
return 'vmess://' + utf8ToBase64(JSON.stringify(params))
}
}
+49
View File
@@ -0,0 +1,49 @@
const seq = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
const RandomUtil = {
randomIntRange(min: number, max: number): number {
return parseInt((Math.random() * (max - min) + min).toString(), 10)
},
randomInt(n: number) {
return this.randomIntRange(0, n)
},
randomSeq(count: number): string {
let str = ''
for (let i = 0; i < count; ++i) {
str += seq[this.randomInt(62)]
}
return str
},
randomLowerAndNum(count: number): string {
let str = ''
for (let i = 0; i < count; ++i) {
str += seq[this.randomInt(36)]
}
return str
},
randomUUID(): string {
let d = new Date().getTime()
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
let r = (d + Math.random() * 16) % 16 | 0
d = Math.floor(d / 16)
return (c === 'x' ? r : (r & 0x7 | 0x8)).toString(16)
})
},
randomShadowsocksPassword(n: number): string {
const array = new Uint8Array(n)
window.crypto.getRandomValues(array)
return btoa(String.fromCharCode(...array))
},
randomShortId(): string[] {
let shortIds = ['','','','']
for (var ii = 0; ii < 4; ii++) {
for (var jj = 0; jj < this.randomInt(8); jj++){
let randomNum = this.randomInt(256)
shortIds[ii] += ('0' + randomNum.toString(16)).slice(-2)
}
}
return shortIds
}
}
export default RandomUtil
+153
View File
@@ -0,0 +1,153 @@
import { i18n } from "@/locales"
type OBJ = {
[key: string]: any
}
export const FindDiff = {
Config(obj1: OBJ, obj2: OBJ): any[] {
const differences: any[] = []
if(!obj2){
return [ { key: "all", obj: obj1 } ]
}
for (const key in obj1) {
if (obj2.hasOwnProperty(key)) {
const value1 = obj1[key]
const value2 = obj2[key]
if (Array.isArray(value1)){
value1.forEach((v1,index) => {
if(index >= value2.length){
differences.push({key: key, action: "new", index: index, obj: v1})
} else if(!this.deepCompare(v1,value2[index])) {
differences.push({key: key, action: "edit", index: index, obj: v1})
}
})
} else {
if (!this.deepCompare(value1,value2)) {
differences.push({ key: key, action: "set", obj: value1})
}
}
} else {
differences.push({ key: key, action: "set", obj: obj1[key]})
}
}
return differences
},
Clients(value1: any[], value2: any[]): any {
const differences: any[] = []
value1.forEach((v1,index) => {
if(index >= value2.length) differences.push({key: "clients", action: "new", obj: v1})
else if(!this.deepCompare(v1,value2[index])) differences.push({key: "clients", action: "edit", obj: v1})
})
return differences
},
Settings(obj1: OBJ, obj2: OBJ): any {
const differences: any[] = []
for (const key in obj1) {
if (obj1[key] != obj2[key]) {
differences.push({ key: key, action: "set", obj: obj1[key]})
}
}
return differences
},
deepCompare(obj1: any, obj2: any): boolean {
// Check if the types of both objects are the same
if (typeof obj1 !== typeof obj2) {
return false
}
// Check if both objects are arrays
if (Array.isArray(obj1) && Array.isArray(obj2)) {
if (obj1.length !== obj2.length) {
return false
}
for (let i = 0; i < obj1.length; i++) {
if (!this.deepCompare(obj1[i], obj2[i])) {
return false
}
}
return true
}
// Check if both objects are plain objects
if (typeof obj1 === 'object' && typeof obj2 === 'object' && obj1 !== null && obj2 !== null) {
const keys1 = Object.keys(obj1)
const keys2 = Object.keys(obj2)
if (keys1.length !== keys2.length) {
return false
}
for (const key of keys1) {
if (!keys2.includes(key) || !this.deepCompare(obj1[key], obj2[key])) {
return false
}
}
return true
}
// Check primitive values
return obj1 === obj2
}
}
const ONE_KB = 1024
const ONE_MB = ONE_KB * 1024
const ONE_GB = ONE_MB * 1024
const ONE_TB = ONE_GB * 1024
const ONE_PB = ONE_TB * 1024
export const HumanReadable = {
sizeFormat(size:number, fix:number=2) {
if (!size || size<0) return "-"
if (size < ONE_KB) {
return size.toFixed(0) + " " + i18n.global.t('stats.B')
} else if (size < ONE_MB) {
return (size / ONE_KB).toFixed(fix) + " " + i18n.global.t('stats.KB')
} else if (size < ONE_GB) {
return (size / ONE_MB).toFixed(fix) + " " + i18n.global.t('stats.MB')
} else if (size < ONE_TB) {
return (size / ONE_GB).toFixed(fix) + " " + i18n.global.t('stats.GB')
} else if (size < ONE_PB) {
return (size / ONE_TB).toFixed(fix) + " " + i18n.global.t('stats.TB')
} else {
return (size / ONE_PB).toFixed(fix) + " " + i18n.global.t('stats.PB')
}
},
packetFormat(size:number, fix:number=2) {
if (!size || size<0) return "-"
if (size < 1000) {
return size.toFixed(0) + " " + i18n.global.t('stats.p')
} else if (size < 1000000) {
return (size / 1000).toFixed(fix) + " " + i18n.global.t('stats.Kp')
} else if (size < 1000000000) {
return (size / 1000000).toFixed(fix) + " " + i18n.global.t('stats.Mp')
} else {
return (size / 1000000000).toFixed(fix) + " " + i18n.global.t('stats.Gp')
}
},
formatSecond(second:number): string {
if (!second || second<0) return "-"
if (second < 60) {
return second.toFixed(0) + i18n.global.t('date.s')
} else if (second < 3600) {
return (second / 60).toFixed(0) + i18n.global.t('date.m')
} else if (second < 3600 * 24) {
return (second / 3600).toFixed(0) + i18n.global.t('date.h')
}
const day = Math.floor(second / 3600 / 24)
const remain = Math.floor((second/3600) - (day*24))
return day + i18n.global.t('date.d') + (remain > 0 ? ' ' + remain + i18n.global.t('date.h') : '')
},
remainedDays(exp:number): number|null {
if (exp == 0) return -1
const now = Date.now()/1000
if (exp < now) return null
return Math.floor((exp - now) / (3600*24))
}
}
+58
View File
@@ -0,0 +1,58 @@
/**
* plugins/vuetify.ts
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import colors from 'vuetify/util/colors'
import { fa, en } from 'vuetify/locale'
// Composables
import { createVuetify } from 'vuetify'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
defaults: {
VRow: { dense: true } // Apply dense to v-row as default
},
theme: {
defaultTheme: localStorage.getItem('theme') ?? 'light',
themes: {
light: {
colors: {
primary: '#1867C0',
secondary: '#5CBBF6',
tertiary: '#E57373',
accent: '#005CAF',
error: colors.red.accent3,
warning: colors.amber.base,
info: colors.teal.darken1,
success: colors.green.base,
background: colors.grey.lighten4,
},
},
dark: {
colors: {
primary: colors.blue.darken4,
secondary: colors.grey.darken3,
accent: colors.pink.darken3,
error: colors.red.accent3,
warning: colors.amber.darken3,
info: colors.teal.lighten1,
success: colors.green.darken2,
surface: colors.grey.darken3,
background: colors.grey.darken4,
},
},
},
},
locale: {
locale: localStorage.getItem("locale") ?? 'en',
fallback: 'en',
messages: { en, fa },
},
})
+98
View File
@@ -0,0 +1,98 @@
// Composables
import { createRouter, createWebHistory } from 'vue-router'
import Login from '@/views/Login.vue'
import Data from '@/store/modules/data'
const routes = [
{
path: '/login',
name: 'pages.login',
component: Login,
},
{
path: '/',
component: () => import('@/layouts/default/Default.vue'),
meta: { requiresAuth: true },
children: [
{
path: '/',
name: 'pages.home',
component: () => import('@/views/Home.vue'),
},
{
path: '/inbounds',
name: 'pages.inbounds',
component: () => import('@/views/Inbounds.vue'),
},
{
path: '/clients',
name: 'pages.clients',
component: () => import('@/views/Clients.vue'),
},
{
path: '/outbounds',
name: 'pages.outbounds',
component: () => import('@/views/Outbounds.vue'),
},
{
path: '/rules',
name: 'pages.rules',
component: () => import('@/views/Rules.vue'),
},
{
path: '/basics',
name: 'pages.basics',
component: () => import('@/views/Basics.vue'),
},
{
path: '/settings',
name: 'pages.settings',
component: () => import('@/views/Settings.vue'),
},
],
},
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
})
const DEFAULT_TITLE = 'S-UI'
let intervalId:any
// Navigation guard to check authentication state
router.beforeEach((to, from, next) => {
// Check the session cookie
const sessionCookie = document.cookie.split(';').find(cookie => cookie.trim().startsWith('session='))
const isAuthenticated = !!sessionCookie
// If the route requires authentication and the user is not authenticated, redirect to /login
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else if (to.path === '/login' && isAuthenticated) {
// If already authenticated and visiting /route, redirect to '/'
next('/')
} else {
// Load default data
if(to.path != '/login'){
loadDataInterval()
} else {
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
next()
}
})
const loadDataInterval = () => {
if (intervalId) return
Data().loadData()
intervalId = setInterval(() => {
Data().loadData()
}, 10000)
}
export default router
+5
View File
@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
+67
View File
@@ -0,0 +1,67 @@
import { FindDiff } from '@/plugins/utils'
import HttpUtils from '@/plugins/httputil'
import { defineStore } from 'pinia'
import { onMounted } from 'vue'
const Data = defineStore('Data', {
state: () => ({
lastLoad: 0,
reloadItems: localStorage.getItem("reloadItems")?.split(',')?? <string[]>[],
subURI: "",
onlines: {inbound: <string[]>[], outbound: <string[]>[], user: <string[]>[]},
oldData: <{config: any, clients: any[]}>{},
config: {},
clients: [],
}),
actions: {
async loadData() {
const msg = await HttpUtils.get('/api/load', this.lastLoad >0 ? {lu: this.lastLoad} : {} )
if(msg.success) {
this.lastLoad = Math.floor((new Date()).getTime()/1000)
// Set new data
const data = JSON.parse(msg.obj)
if (data.config) this.config = data.config
if (data.clients) this.clients = data.clients
if (data.subURI) this.subURI = data.subURI
this.onlines = data.onlines
// To avoid ref copy
if (data.config) this.oldData.config = { ...JSON.parse(msg.obj).config }
if (data.clients) this.oldData.clients = [ ...JSON.parse(msg.obj).clients ]
}
},
async pushData() {
const diff = {
config: JSON.stringify(FindDiff.Config(this.config,this.oldData.config)),
clients: JSON.stringify(FindDiff.Clients(this.clients,this.oldData.clients)),
}
const msg = await HttpUtils.post('/api/save',diff)
if(msg.success) {
this.loadData()
}
},
async delInbound(index: number) {
const diff = {
config: JSON.stringify([{key: "inbounds", action: "del", index: index, obj: null}]),
clients: JSON.stringify(FindDiff.Clients(this.clients,this.oldData.clients)),
}
const msg = await HttpUtils.post('/api/save',diff)
if(msg.success) {
this.loadData()
}
},
async delClient(id: number) {
const diff = {
config: JSON.stringify(FindDiff.Config(this.config,this.oldData.config)),
clients:JSON.stringify([{key: "clients", action: "del", index: id, obj: null}]),
}
const msg = await HttpUtils.post('/api/save',diff)
if(msg.success) {
this.loadData()
}
}
},
})
export default Data
+22
View File
@@ -0,0 +1,22 @@
import { defineStore } from 'pinia'
const Message = defineStore('msg', {
state: () => ({
showMsg: false,
snackbar: {
message: '',
timeout: 5000,
color: '',
}
}),
actions: {
showMessage(message:string, color='success',timeout=5000) {
this.snackbar.message = message
this.snackbar.color = color
this.snackbar.timeout = timeout
this.showMsg = true
}
},
})
export default Message
+39
View File
@@ -0,0 +1,39 @@
/**
* src/styles/settings.scss
*
* Configures SASS variables and Vuetify overwrites
*/
// https://vuetifyjs.com/features/sass-variables/`
// @use 'vuetify/settings' with (
// $color-pack: false
// );
@font-face {
font-display: swap;
font-family: 'Vazirmatn';
font-style: normal;
font-weight: 400;
src: url('@/assets/Vazirmatn-UI-NL-Regular.woff2') format('woff2');
unicode-range: U+0600-06FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE80-FEFC, U+0030-0039;
}
$body-font-family: "Vazirmatn";
$typoOptions: text-h1, text-sm-h1, text-md-h1, text-lg-h1, text-h2, text-sm-h2,
text-md-h2, text-lg-h2, text-h3, text-sm-h3, text-md-h3, text-lg-h3, text-h4,
text-sm-h4, text-md-h4, text-lg-h4, text-h5, text-sm-h5, text-md-h5,
text-lg-h5, text-h6, text-sm-h6, text-md-h6, text-lg-h6, headline, title,
subtitle-1, subtitle-2, text-body-1, text-sm-body-1, text-md-body-1,
text-lg-body-1, text-body-2, text-sm-body-2, text-md-body-2, text-lg-body-2,
text-caption;
body {
font-family: -apple-system, BlinkMacSystemFont, $body-font-family, sans-serif !important;
@each $typoOption in $typoOptions {
.#{$typoOption} {
font-family: -apple-system, BlinkMacSystemFont, $body-font-family, sans-serif !important;
}
}
}
.v-btn {
letter-spacing: 0;
}
+115
View File
@@ -0,0 +1,115 @@
import RandomUtil from "@/plugins/randomUtil"
export interface Client {
id?: number
enable: boolean
name: string
config: string
inbounds: string
links: string
volume: number
expiry: number
up: number
down: number
}
const defaultClient: Client = {
enable: true,
name: "",
config: "[]",
inbounds: "",
links: "[]",
volume: 0,
expiry: 0,
up: 0,
down: 0,
}
type Config = {
[key: string]: {
name?: string
username?: string
[key: string]: any
}
}
export function updateConfigs(configs: string, newUserName: string): string {
const updatedConfigs: Config = JSON.parse(configs)
for (const key in updatedConfigs) {
if (updatedConfigs.hasOwnProperty(key)) {
const config = updatedConfigs[key]
if (config.hasOwnProperty("name")) {
config.name = newUserName
} else if (config.hasOwnProperty("username")) {
config.username = newUserName
}
}
}
return JSON.stringify(updatedConfigs)
}
export function randomConfigs(user: string): Config {
const mixedPassword = RandomUtil.randomSeq(10)
const ssPassword = RandomUtil.randomShadowsocksPassword(32)
const uuid = RandomUtil.randomUUID()
return {
mixed: {
username: user,
password: mixedPassword,
},
socks: {
username: user,
password: mixedPassword,
},
http: {
username: user,
password: mixedPassword,
},
shadowsocks: {
name: user,
password: ssPassword,
},
shadowtls: {
name: user,
password: ssPassword,
},
vmess: {
name: user,
uuid: uuid,
alterId: 0,
},
vless: {
name: user,
uuid: uuid,
flow: "xtls-rprx-vision",
},
trojan: {
name: user,
password: mixedPassword,
},
naive: {
username: user,
password: mixedPassword,
},
hysteria: {
name: user,
auth_str: mixedPassword,
},
tuic: {
name: user,
uuid: uuid,
password: mixedPassword,
},
hysteria2: {
name: user,
password: mixedPassword,
},
}
}
export function createClient<T extends Client>(json?: Partial<T>): Client {
const defaultObject: Client = { ...defaultClient, ...(json || {}) }
return defaultObject
}
+126
View File
@@ -0,0 +1,126 @@
import { Inbound } from './inbounds'
import { Dial, Outbound } from './outbounds'
interface Log {
disabled?: boolean
level?: string
output?: string
timestamp?: boolean
}
interface Dns {
servers: DnsServer[]
final?: string
strategy?: string
}
interface DnsServer{
tag?: string,
address: string,
address_resolver?: string,
address_strategy?: string,
strategy?: string,
detour?: string
}
export interface Ntp extends Dial{
enabled?: boolean
server: string
server_port?: number
interval?: string
}
interface Route {
rules: RouteRule[] | RouteRuleLogical[]
rule_set: RouteRuleSet[]
final?: string,
auto_detect_interface?: boolean
default_interface?: string
default_mark?: number
}
interface RouteRule {
inbound?: string[] | string
ip_version?: 4 | 6,
network?: "tcp" | "udp"
auth_user?: string[]
protocol?: string[] | string
domain?: string[] | string
domain_suffix?: string[] | string
domain_keyword?: string[] | string
domain_regex?: string[] | string
source_ip_cidr?: string[] | string
source_ip_is_private?: boolean
ip_cidr?: string[] | string
ip_is_private?: boolean
source_port?: number[] | number
source_port_range?: string[] | string
port?: number[] | number
port_range?: string[] | string
clash_mode?: string
rule_set?: string[] | string
invert?: boolean
outbound: string
}
interface RouteRuleLogical {
type: "logical"
mode: "and" | "or"
rules: RouteRule[]
invert?: boolean
outbound: string
}
interface RouteRuleSet {
type: string
tag: string
format: string
path?: string
url?: string
download_detour?: string
update_interval?: string
}
interface Experimental {
cache_file?: CacheFile
clash_api?: ClashApi
v2ray_api: V2rayApi
}
interface CacheFile {
enabled?: boolean
path?: string
cache_id?: string
store_fakeip?: boolean
}
interface V2rayApi {
listen: string
stats: V2rayApiStats
}
export interface V2rayApiStats {
enabled: boolean
inbounds: string[]
outbounds: string[]
users: string[]
}
interface ClashApi {
external_controller?: string
external_ui?: string
external_ui_download_url?: string
external_ui_download_detour?: string
secret?: string
default_mode?: string
}
export interface Config {
log: Log
dns: Dns
ntp?: Ntp
inbounds: Inbound[]
outbounds: Outbound[]
route: Route
experimental: Experimental
}
+14
View File
@@ -0,0 +1,14 @@
export interface Dial {
detour?: string
bind_interface?: string
inet4_bind_address?: string
inet6_bind_address?:string
routing_mark?: number
reuse_addr?: boolean
connect_timeout?: string
tcp_fast_open?: boolean
tcp_multi_path?: boolean
udp_fragment?: boolean
domain_strategy?: string
fallback_delay?: string
}
+11
View File
@@ -0,0 +1,11 @@
interface Brutal {
enabled: boolean
up_mbps: number
down_mbps: number
}
export interface iMultiplex{
enabled: boolean
padding?: boolean
brutal?: Brutal
}
+19
View File
@@ -0,0 +1,19 @@
export interface iTls {
enabled?: boolean
server_name?: string
alpn?: string[]
min_version?: string
max_version?: string
cipher_suites?: string[]
certificate?: string[]
certificate_path?: string
key?: string[]
key_path?: string
}
export const defaultInTls: iTls = {
alpn: ['HTTP/3', 'HTTP/2', 'HTTP/1.1'],
min_version: "1.2",
max_version: "1.3",
cipher_suites: [""],
}
+242
View File
@@ -0,0 +1,242 @@
import { iMultiplex } from "./inMultiplex"
import { iTls } from "./inTls"
import { Dial } from "./outbounds"
import { Transport } from "./transport"
export const InTypes = {
Direct: 'direct',
Mixed: 'mixed',
SOCKS: 'socks',
HTTP: 'http',
Shadowsocks: 'shadowsocks',
VMess: 'vmess',
Trojan: 'trojan',
Naive: 'naive',
Hysteria: 'hysteria',
ShadowTLS: 'shadowtls',
TUIC: 'tuic',
Hysteria2: 'hysteria2',
VLESS: 'vless',
// Tun: 'tun',
Redirect: 'redirect',
TProxy: 'tproxy',
}
type InType = typeof InTypes[keyof typeof InTypes]
export interface Listen {
listen: string
listen_port: number
tcp_fast_open?: boolean
tcp_multi_path?: boolean
udp_fragment?: boolean
udp_timeout?: string
detour?: string
sniff?: boolean
sniff_override_destination?: boolean
sniff_timeout?: string
domain_strategy?: string
}
interface InboundBasics extends Listen {
type: InType
tag: string
}
interface UsernamePass {
username: string
password: string
}
interface NamePass {
name: string
password: string
}
interface NameUUID {
name: string
uuid: string
}
interface NameAuth {
name: string
auth_str: string
}
interface VmessUser extends NameUUID {
alterId: number
}
interface VlessUser extends NameUUID {
flow: string
}
interface TuicUser extends NameUUID {
password?: string
}
interface ShadowTLSHandShake extends Dial {
server: string
server_port: number
}
export interface Direct extends InboundBasics {
network?: "udp" | "tcp"
override_address?: string
override_port?: number
}
export interface Mixed extends InboundBasics {
users?: UsernamePass[]
}
export interface SOCKS extends InboundBasics {
users?: UsernamePass[]
}
export interface HTTP extends InboundBasics {
users?: UsernamePass[]
tls?: iTls,
}
export interface Shadowsocks extends InboundBasics {
method: string
password: string
network?: "udp" | "tcp"
users?: NamePass[]
multiplex?: iMultiplex
}
export interface VMess extends InboundBasics {
users: VmessUser[]
tls: iTls
multiplex?: iMultiplex
transport?: Transport
}
export interface Trojan extends InboundBasics {
users: NamePass[]
tls: iTls
fallback?: {
server: string
server_port: number
}
multiplex?: iMultiplex
transport?: Transport
}
export interface Naive extends InboundBasics {
users: UsernamePass[]
tls: iTls,
}
export interface Hysteria extends InboundBasics {
up_mbps: number
down_mbps: number
obfs?: {
type?: "salamander"
password?: string
}
users: NameAuth[]
recv_window_conn?: number
recv_window_client?: number
max_conn_client?: number
disable_mtu_discovery?: boolean
tls: iTls
}
export interface ShadowTLS extends InboundBasics {
version: 1|2|3
password?: string
users?: NamePass[]
handshake: ShadowTLSHandShake
handshake_for_server_name?: {
[server_name: string]: ShadowTLSHandShake
}
strict_mode?: boolean
}
export interface VLESS extends InboundBasics {
users: VlessUser[]
tls?: iTls
multiplex?: iMultiplex
transport?: Transport
}
export interface TUIC extends InboundBasics {
users: TuicUser[]
congestion_control: ""|"cubic"|"new_reno"|"bbr"
auth_timeout?: string
zero_rtt_handshake?: boolean
heartbeat?: string
tls: iTls
}
export interface Hysteria2 extends InboundBasics {
up_mbps?: number
down_mbps?: number
obfs?: {
type?: "salamander"
password: string
}
users: NamePass[]
ignore_client_bandwidth?: boolean
tls: iTls
masquerade?: string
brutal_debug?: boolean
}
export interface Tun extends InboundBasics {
[otherProperties: string]: any
}
export interface Redirect extends InboundBasics {}
export interface TProxy extends InboundBasics {
network?: "udp" | "tcp"
}
// Create interfaces dynamically based on InTypes keys
type InterfaceMap = {
direct: Direct
mixed: Mixed
socks: SOCKS
http: SOCKS
shadowsocks: Shadowsocks
vmess: VMess
trojan: Trojan
naive: Naive
hysteria: Hysteria
shadowtls: ShadowTLS
tuic: TUIC
hysteria2: Hysteria2
vless: VLESS
// tun: Tun
redirect: Redirect
tproxy: TProxy
}
// Create union type from InterfaceMap
export type Inbound = InterfaceMap[keyof InterfaceMap]
type userEnabledTypes = {
mixed: Mixed
socks: SOCKS
http: SOCKS
shadowsocks: Shadowsocks
vmess: VMess
trojan: Trojan
naive: Naive
hysteria: Hysteria
shadowtls: ShadowTLS
tuic: TUIC
hysteria2: Hysteria2
vless: VLESS
}
// Create union type from userEnabledTypes
export type InboundWithUser = userEnabledTypes[keyof userEnabledTypes]
// Create defaultValues object dynamically
const defaultValues: Record<InType, Inbound> = {
direct: <Direct>{ type: InTypes.Direct },
mixed: <Mixed>{ type: InTypes.Mixed },
socks: <SOCKS>{ type: InTypes.SOCKS },
http: <HTTP>{ type: InTypes.HTTP, tls: {} },
shadowsocks: <Shadowsocks>{ type: InTypes.Shadowsocks, method: 'none', multiplex: {} },
vmess: <VMess>{ type: InTypes.VMess, users: <VmessUser[]>[], tls: {}, multiplex: {}, transport: {} },
trojan: <Trojan>{ type: InTypes.Trojan, users: <NamePass[]>[], tls: {}, multiplex: {}, transport: {} },
naive: <Naive>{ type: InTypes.Naive, users: <UsernamePass[]>[], tls: { enabled: true } },
hysteria: <Hysteria>{ type: InTypes.Hysteria, users: <NameAuth[]>[], up_mbps: 100, down_mbps: 100, tls: { enabled: true } },
shadowtls: <ShadowTLS>{ type: InTypes.ShadowTLS, version: 3, users: <NamePass[]>[], handshake: {}, handshake_for_server_name: {} },
tuic: <TUIC>{ type: InTypes.TUIC, users: <TuicUser[]>[], congestion_control: "cubic", tls: { enabled: true } },
hysteria2: <Hysteria2>{ type: InTypes.Hysteria2, users: <NamePass[]>[], tls: { enabled: true } },
vless: <VLESS>{ type: InTypes.VLESS, users: <VlessUser[]>[], tls: {}, multiplex: {}, transport: {} },
// tun: <Tun>{ type: InTypes.Tun },
redirect: <Redirect>{ type: InTypes.Redirect },
tproxy: <TProxy>{ type: InTypes.TProxy },
}
export function createInbound<T extends Inbound>(type: InType,json?: Partial<T>): Inbound {
const defaultObject: Inbound = { ...defaultValues[type] ?? {}, ...(json ?? {}) }
return defaultObject
}
+3
View File
@@ -0,0 +1,3 @@
export interface oTls {
enabled?: boolean
}
+88
View File
@@ -0,0 +1,88 @@
import { oTls } from "./outTls"
export const OutTypes = {
Direct: 'direct',
Block: 'block',
SOCKS: 'socks',
HTTP: 'http',
Shadowsocks: 'shadowsocks',
VMess: 'vmess',
Trojan: 'trojan',
Wireguard: 'wireguard',
Hysteria: 'hysteria',
VLESS: 'vless',
ShadowTLS: 'shadowtls',
TUIC: 'tuic',
Hysteria2: 'hysteria2',
Tur: 'tur',
SSH: 'ssh',
DNS: 'dns',
Selector: 'selector',
URLTest: 'urltest',
}
type OutType = typeof OutTypes[keyof typeof OutTypes]
export interface Dial {
detour?: string
bind_interface?: string
inet4_bind_address?: string
inet6_bind_address?: string
routing_mark?: number
reuse_addr?: boolean
connect_timeout?: string
tcp_fast_open?: boolean
tcp_multi_path?: boolean
udp_fragment?: boolean
domain_strategy?: string
fallback_delay?: string
}
interface OutboundBasics {
type: OutType
tag: string
}
export interface Direct extends OutboundBasics, Dial {
override_address?: string
override_port?: number
proxy_protocol?: 0 | 1 | 2
}
// Create interfaces dynamically based on OutTypes keys
type InterfaceMap = {
[Key in keyof typeof OutTypes]: {
type: string
[otherProperties: string]: any; // You can add other properties as needed
}
}
// Create union type from InterfaceMap
export type Outbound = InterfaceMap[keyof InterfaceMap]
// Create defaultValues object dynamically
const defaultValues: Record<OutType, Outbound> = {
direct: { type: OutTypes.Direct },
block: { type: OutTypes.Block },
socks: { type: OutTypes.SOCKS },
http: { type: OutTypes.HTTP },
shadowsocks: { type: OutTypes.Shadowsocks },
vmess: { type: OutTypes.VMess, tls: { enabled: true } },
trojan: { type: OutTypes.Trojan },
wireguard: { type: OutTypes.Wireguard },
hysteria: { type: OutTypes.Hysteria },
vless: { type: OutTypes.VLESS },
shadowtls: { type: OutTypes.ShadowTLS },
tuic: { type: OutTypes.TUIC },
hysteria2: { type: OutTypes.Hysteria2, users: [], tls: {} },
tur: { type: OutTypes.Tur },
ssh: { type: OutTypes.SSH },
dns: { type: OutTypes.DNS },
selector: { type: OutTypes.Selector },
urltest: { type: OutTypes.URLTest },
}
export function createOutbound<T extends Outbound>(type: string,json?: Partial<T>): Outbound {
const defaultObject: Outbound = { ...defaultValues[type], ...(json || {}) }
return defaultObject
}
+46
View File
@@ -0,0 +1,46 @@
export const TrspTypes = {
HTTP: 'http',
WebSocket: 'ws',
QUIC: 'quic',
gRPC: 'grpc',
HTTPUpgrade: "httpupgrade"
}
export type TrspType = typeof TrspTypes[keyof typeof TrspTypes]
export type Transport = HTTP|WebSocket|QUIC|gRPC|HTTPUpgrade
interface TransportBasics {
type: TrspType
}
export interface HTTP extends TransportBasics {
host?: string[]
path?: string
method?: string
headers?: {}
idle_timeout?: string
ping_timeout?: string
}
export interface WebSocket extends TransportBasics {
path: string
headers?: {}
max_early_data?: number
early_data_header_name?: string
}
export interface QUIC extends TransportBasics {}
export interface gRPC extends TransportBasics {
service_name?: string
idle_timeout?: string
ping_timeout?: string
permit_without_stream?: boolean
}
export interface HTTPUpgrade extends TransportBasics {
host?: string
path?: string
headers?: {}
}
+346
View File
@@ -0,0 +1,346 @@
<template>
<v-expansion-panels>
<v-expansion-panel title="Log">
<v-expansion-panel-text>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-switch v-model="appConfig.log.disabled" color="primary" :label="$t('disable')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-select
hide-details
label="Level"
:items="levels"
v-model="appConfig.log.level">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-text-field
v-model="appConfig.log.output"
hide-details
label="Output"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-switch v-model="appConfig.log.timestamp" color="primary" label="Timestamp" hide-details></v-switch>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel title="DNS">
<v-expansion-panel-text>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-select
hide-details
label="Final"
:items="[ {title: 'First Server', value: ''}, ...dnsServersTags]"
v-model="finalDns">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-select
hide-details
label="Domain to IP Strategy"
clearable
@click:clear="delete appConfig.dns.strategy"
:items="['prefer_ipv4','prefer_ipv6','ipv4_only','ipv6_only']"
v-model="appConfig.dns.strategy">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="3" align-self="center">
<v-btn @click="addDnsServer" rounded>
<v-icon icon="mdi-plus" />Server
</v-btn>
</v-col>
</v-row>
<template v-for="(s, index) in appConfig.dns.servers">
Server {{ index+1 }} <v-icon icon="mdi-delete" @click="appConfig.dns.servers.splice(index,1)" />
<v-divider></v-divider>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-text-field
v-model="s.tag"
hide-details
clearable
@click:clear="delete s.tag"
label="Tag"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-text-field
v-model="s.address"
hide-details
label="Address"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-select
hide-details
label="Outbound"
clearable
@click:clear="delete s.detour"
:items="outboundTags"
v-model="s.detour">
</v-select>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-select
hide-details
label="Domain Strategy"
clearable
@click:clear="delete s.strategy"
:items="['prefer_ipv4','prefer_ipv6','ipv4_only','ipv6_only']"
v-model="s.strategy">
</v-select>
</v-col>
</v-row>
</template>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel title="NTP">
<v-expansion-panel-text>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-switch v-model="enableNtp" color="primary" :label="$t('enable')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.ntp?.enabled">
<v-text-field
v-model="appConfig.ntp.server"
hide-details
label="Server"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.ntp?.enabled">
<v-text-field
v-model="appConfig.ntp.server_port"
hide-details
type="number"
clearable
@click:clear="delete appConfig.ntp.server_port"
label="Server Port"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.ntp?.enabled">
<v-text-field
v-model="ntpInterval"
hide-details
suffix="m"
min="0"
type="number"
label="Interval"
></v-text-field>
</v-col>
</v-row>
<Dial :dial="appConfig.ntp" v-if="appConfig.ntp?.enabled" />
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel title="Experimental">
<v-expansion-panel-text>
Cache File
<v-divider></v-divider>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-switch v-model="enableCacheFile" color="primary" :label="$t('enable')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.experimental.cache_file">
<v-text-field
v-model="appConfig.experimental.cache_file.path"
hide-details
label="Path"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.experimental.cache_file">
<v-text-field
v-model="appConfig.experimental.cache_file.cache_id"
hide-details
label="Cache ID"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.experimental.cache_file">
<v-switch v-model="appConfig.experimental.cache_file.store_fakeip"
color="primary"
label="Store Fake IP"
hide-details></v-switch>
</v-col>
</v-row>
Clash API
<v-divider></v-divider>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-switch v-model="enableClashApi" color="primary" :label="$t('enable')" hide-details></v-switch>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.experimental.clash_api">
<v-text-field
v-model="appConfig.experimental.clash_api.external_controller"
hide-details
label="External Controller"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.experimental.clash_api">
<v-text-field
v-model="appConfig.experimental.clash_api.external_ui"
hide-details
label="External UI"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.experimental.clash_api">
<v-text-field
v-model="appConfig.experimental.clash_api.external_ui_download_url"
hide-details
label="UI Download URL"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.experimental.clash_api">
<v-text-field
v-model="appConfig.experimental.clash_api.external_ui_download_detour"
hide-details
label="UI Download detour"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.experimental.clash_api">
<v-text-field
v-model="appConfig.experimental.clash_api.secret"
hide-details
label="Secret"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3" v-if="appConfig.experimental.clash_api">
<v-text-field
v-model="appConfig.experimental.clash_api.default_mode"
hide-details
label="Default Mode"
></v-text-field>
</v-col>
</v-row>
V2Ray API
<v-divider></v-divider>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-text-field
v-model="appConfig.experimental.v2ray_api.listen"
hide-details
label="Listen"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-switch v-model="appConfig.experimental.v2ray_api.stats.enabled"
color="primary"
:label="$t('stats.enable')"
hide-details></v-switch>
</v-col>
</v-row>
<v-row v-if="appConfig.experimental.v2ray_api.stats.enabled">
<v-col cols="12" sm="6">
<v-select
hide-details
:label="$t('pages.inbounds')"
multiple chips closable-chips
:items="inboundTags"
v-model="appConfig.experimental.v2ray_api.stats.inbounds">
</v-select>
</v-col>
<v-col cols="12" sm="6">
<v-select
hide-details
:label="$t('pages.outbounds')"
multiple chips closable-chips
:items="outboundTags"
v-model="appConfig.experimental.v2ray_api.stats.outbounds">
</v-select>
</v-col>
<v-col cols="12" sm="6">
<v-select
hide-details
:label="$t('pages.clients')"
multiple chips closable-chips
:items="clientNames"
v-model="appConfig.experimental.v2ray_api.stats.users">
</v-select>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</template>
<script lang="ts" setup>
import Data from '@/store/modules/data';
import Dial from '@/components/Dial.vue';
import { computed } from 'vue';
import { Config, Ntp } from '@/types/config';
import { Client } from '@/types/clients';
const appConfig = computed((): Config => {
return <Config> Data().config
})
const inboundTags = computed((): string[] => {
return appConfig.value.inbounds.map(i => i.tag)
})
const outboundTags = computed((): string[] => {
return appConfig.value.outbounds.map(o => o.tag)
})
const clientNames = computed((): string[] => {
const clients = <Client[]>Data().clients
return clients?.map(c => c.name)
})
const levels = ["trace", "debug", "info", "warn", "error", "fatal", "panic"]
const dnsServersTags = computed((): string[] => {
const s = <string[]>appConfig.value.dns.servers?.filter(s => s.tag && s.tag != "")?.map(s => s.tag)
return s?? <string[]>[]
})
const finalDns = computed({
get() { return appConfig.value.dns.final?? '' },
set(v:string) { appConfig.value.dns.final = v.length>0 ? v : undefined }
})
const addDnsServer = () => {
if (!appConfig.value.dns.servers) appConfig.value.dns.servers = []
appConfig.value.dns.servers.push({address: 'local'})
}
const enableNtp = computed({
get() { return appConfig.value.ntp?.enabled?? false },
set(v:boolean) {
if (v){
appConfig.value.ntp = <Ntp>{ enabled: true, server: 'time.apple.com', server_port: 123, interval: '30m'}
} else { appConfig.value.ntp = <Ntp>{} }
}
})
const ntpInterval = computed({
get():any { return appConfig.value.ntp?.interval? parseInt(appConfig.value.ntp?.interval.replace('m','')) : null },
set(v:number) { if (appConfig.value.ntp) v>0 ? appConfig.value.ntp.interval = v + 'm' : delete appConfig.value.ntp.interval }
})
const enableCacheFile = computed({
get() { return appConfig.value.experimental.cache_file?.enabled?? false },
set(v:boolean) {
if (v){
appConfig.value.experimental.cache_file = { enabled: true }
} else { delete appConfig.value.experimental.cache_file }
}
})
const enableClashApi = computed({
get() { return appConfig.value.experimental.clash_api != undefined },
set(v:boolean) {
if (v){
appConfig.value.experimental.clash_api = {}
} else { delete appConfig.value.experimental.clash_api }
}
})
</script>
<style>
.v-card-subtitle {
text-align: center;
border-bottom: 1px solid gray;
}
</style>
+303
View File
@@ -0,0 +1,303 @@
<template>
<ClientModal
v-model="modal.visible"
:visible="modal.visible"
:index="modal.index"
:data="modal.data"
:stats="modal.stats"
:inboundTags="inboundTags"
@close="closeModal"
@save="saveModal"
/>
<QrCode
v-model="qrcode.visible"
:visible="qrcode.visible"
:index="qrcode.index"
@close="closeQrCode"
/>
<Stats
v-model="stats.visible"
:visible="stats.visible"
:resource="stats.resource"
:tag="stats.tag"
@close="closeStats"
/>
<v-row>
<v-col cols="12">
<v-btn color="primary" @click="showModal(-1)">{{ $t('actions.add') }}</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="4" md="3" lg="2" v-for="(item, index) in clients" :key="item.id">
<v-card rounded="xl" elevation="5" min-width="200">
<v-card-title>
<v-row>
<v-col>{{ item.name }}</v-col>
<v-spacer></v-spacer>
<v-col cols="auto">
<v-switch color="primary" v-model="clients[index].enable" hideDetails density="compact" />
</v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col>{{ $t('pages.inbounds') }}</v-col>
<v-col dir="ltr">
<v-tooltip activator="parent" dir="ltr" location="bottom">
<span v-for="i in item.inbounds.split(',')">{{ i }}<br /></span>
</v-tooltip>
{{ item.inbounds.split(',').length }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('stats.volume') }}</v-col>
<v-col dir="ltr">
{{ item.volume == 0 ? $t('unlimited') : HumanReadable.sizeFormat(item.volume) }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('date.expiry') }}</v-col>
<v-col dir="ltr">
{{ item.expiry == 0 ? $t('unlimited') : HumanReadable.remainedDays(item.expiry)?? $t('date.expired') }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('stats.usage') }}</v-col>
<v-col dir="ltr">
<v-tooltip activator="parent" location="bottom">
{{ $t('stats.upload') }}:{{ HumanReadable.sizeFormat(item.up) }}<br />
{{ $t('stats.download') }}:{{ HumanReadable.sizeFormat(item.down) }}<br />
<template v-if="item.volume>0">
{{ $t('remained') }}: {{ HumanReadable.sizeFormat(item.volume - (item.up + item.down)) }}
</template>
</v-tooltip>
{{ item.volume>0 ? HumanReadable.sizeFormat(item.up + item.down) : $t('unlimited') }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('online') }}</v-col>
<v-col dir="ltr">
<template v-if="onlines[index]">
<v-chip density="comfortable" size="small" color="success" variant="flat">{{ $t('online') }}</v-chip>
</template>
<template v-else>-</template>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions style="padding: 0;">
<v-btn icon="mdi-account-edit" @click="showModal(index)">
<v-icon />
<v-tooltip activator="parent" location="top" :text="$t('actions.edit')"></v-tooltip>
</v-btn>
<v-btn style="margin-inline-start:0;" icon="mdi-account-minus" color="warning" @click="delOverlay[index] = true">
<v-icon />
<v-tooltip activator="parent" location="top" :text="$t('actions.del')"></v-tooltip>
</v-btn>
<v-overlay
v-model="delOverlay[index]"
contained
class="align-center justify-center"
>
<v-card :title="$t('actions.del')" rounded="lg">
<v-divider></v-divider>
<v-card-text>{{ $t('confirm') }}</v-card-text>
<v-card-actions>
<v-btn color="error" variant="outlined" @click="delClient(index)">{{ $t('yes') }}</v-btn>
<v-btn color="success" variant="outlined" @click="delOverlay[index] = false">{{ $t('no') }}</v-btn>
</v-card-actions>
</v-card>
</v-overlay>
<v-btn icon="mdi-qrcode" @click="showQrCode(index)" />
<v-btn icon="mdi-chart-line" @click="showStats(item.name)" />
</v-card-actions>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import Data from '@/store/modules/data'
import ClientModal from '@/layouts/modals/Client.vue'
import QrCode from '@/layouts/modals/QrCode.vue'
import Stats from '@/layouts/modals/Stats.vue'
import { Client, createClient } from '@/types/clients'
import { computed, ref } from 'vue'
import { Config, V2rayApiStats } from '@/types/config'
import { InTypes, Inbound,InboundWithUser, ShadowTLS, VLESS } from '@/types/inbounds'
import { Link, LinkUtil } from '@/plugins/link'
import { HumanReadable } from '@/plugins/utils'
const clients = computed((): any[] => {
return Data().clients
})
const onlines = computed(() => {
return Data().onlines.user ? clients.value.map(c => Data().onlines.user.includes(c.name)) : []
})
const appConfig = computed((): Config => {
return <Config> Data().config
})
const v2rayStats = computed((): V2rayApiStats => {
return <V2rayApiStats> appConfig.value.experimental.v2ray_api.stats
})
const inbounds = computed((): Inbound[] => {
return <Inbound[]> appConfig.value.inbounds
})
const inboundTags = computed((): string[] => {
if (!inbounds.value) return []
return inbounds.value.filter(i => i.tag != "" && Object.hasOwn(i,'users')).map(i => i.tag)
})
const modal = ref({
visible: false,
index: -1,
data: "",
stats: false,
})
const delOverlay = ref(new Array<boolean>(clients.value.length).fill(false))
const showModal = (index: number) => {
modal.value.index = index
modal.value.data = index == -1 ? '' : JSON.stringify(clients.value[index])
modal.value.stats = index == -1 ? false : v2rayStats.value.users.includes(clients.value[index].name)
modal.value.visible = true
}
const closeModal = () => {
modal.value.visible = false
}
const saveModal = (data:any, stats:boolean) => {
const inboundTags: string[] = data.inbounds.split(',')?? []
let oldName:string = ""
if(modal.value.index == -1) {
clients.value.push(data)
} else {
const oldData = createClient(clients.value[modal.value.index])
oldName = oldData.name
oldData.inbounds.split(',').forEach((i:string) => {
if (!inboundTags.includes(i)) inboundTags.push(i)
})
clients.value[modal.value.index] = data
}
// Rebuild affected inbounds
buildInboundsUsers(inboundTags)
// Rebuild links
data.links = updateLinks(data)
// Set Client Stats
const sIndex = v2rayStats.value.users.findIndex(i => i == data.name) // Find if new user exists
if (oldName != data.name) {
v2rayStats.value.users = v2rayStats.value.users.filter(item => item != oldName)
}
if (stats) {
// Add if dos not exist
if (data.name.length>0 && sIndex == -1) v2rayStats.value.users.push(data.name)
} else {
// Delete if exists
if (sIndex != -1) v2rayStats.value.users.splice(sIndex,1)
}
modal.value.visible = false
}
const buildInboundsUsers = (inboundTags:string[]) => {
inboundTags.forEach(tag => {
const inbound_index = inbounds.value.findIndex(i => i.tag == tag)
if (inbound_index != -1){
const users = <any>[]
const newInbound = <InboundWithUser>inbounds.value[inbound_index]
const inboundClients = clients.value.filter(c => c.enable && c.inbounds.split(',').includes(tag))
inboundClients.forEach(c => {
const clientConfig = JSON.parse(c.config)
// Remove flow in non tls VLESS
if (newInbound.type == InTypes.VLESS) {
const vlessInbound = <VLESS>newInbound
if (!vlessInbound.tls?.enabled) delete(clientConfig["vless"].flow)
}
users.push(clientConfig[newInbound.type])
})
newInbound.users = users
// Exceptions for Naive and ShadowTLSv3
if (users.length == 0){
if (newInbound.type == InTypes.Naive) {
newInbound.users = <any>[{}]
} else {
if (newInbound.type == InTypes.ShadowTLS){
const ssTls = <ShadowTLS>newInbound
if (ssTls.version == 3) newInbound.users = <any>[{}]
}
}
}
inbounds.value[inbound_index] = newInbound
}
})
}
const updateLinks = (c:Client):string => {
const clientInbounds = <Inbound[]>inbounds.value.filter(i => c.inbounds.split(',').includes(i.tag))
const newLinks = <Link[]>[]
clientInbounds.forEach(i =>{
const uri = LinkUtil.linkGenerator(c.name,i)
if (uri.length>0){
newLinks.push(<Link>{ type: 'local', remark: i.tag, uri: uri })
}
})
let links = c.links && c.links.length>0? <Link[]>JSON.parse(c.links) : <Link[]>[]
links = [...newLinks, ...links.filter(l => l.type != 'local')]
return JSON.stringify(links)
}
const delClient = (clientIndex: number) => {
const id = clients.value[clientIndex].id
const oldData = createClient(clients.value[clientIndex])
// Delete stats if exists and will be orphaned
const tagCounts = clients.value.filter(i => i.name == oldData.name).length
const sIndex = v2rayStats.value.users.findIndex(i => i == oldData.name)
if (tagCounts == 1 && sIndex != -1){
v2rayStats.value.users.splice(sIndex,1)
}
clients.value.splice(clientIndex,1)
buildInboundsUsers(oldData.inbounds.split(','))
if (id>0) Data().delClient(id)
delOverlay.value[clientIndex] = false
}
const qrcode = ref({
visible: false,
index: 0,
})
const showQrCode = (index: number) => {
qrcode.value.index = index
qrcode.value.visible = true
}
const closeQrCode = () => {
qrcode.value.visible = false
}
const stats = ref({
visible: false,
resource: "user",
tag: "",
})
const showStats = (tag: string) => {
stats.value.tag = tag
stats.value.visible = true
}
const closeStats = () => {
stats.value.visible = false
}
</script>
+7
View File
@@ -0,0 +1,7 @@
<template>
<Main />
</template>
<script lang="ts" setup>
import Main from '@/components/Main.vue'
</script>
+289
View File
@@ -0,0 +1,289 @@
<template>
<InboundVue
v-model="modal.visible"
:visible="modal.visible"
:id="modal.id"
:stats="modal.stats"
:data="modal.data"
@close="closeModal"
@save="saveModal"
/>
<Stats
v-model="stats.visible"
:visible="stats.visible"
:resource="stats.resource"
:tag="stats.tag"
@close="closeStats"
/>
<v-row>
<v-col cols="12">
<v-btn color="primary" @click="showModal(-1)">{{ $t('actions.add') }}</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="4" md="3" lg="2" v-for="(item, index) in <any[]>inbounds" :key="item.tag">
<v-card rounded="xl" elevation="5" min-width="200" :title="item.tag">
<v-card-subtitle>
<v-row>
<v-col>{{ item.type }}</v-col>
</v-row>
</v-card-subtitle>
<v-card-text>
<v-row>
<v-col>{{ $t('in.addr') }}</v-col>
<v-col dir="ltr">
{{ item.listen }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('in.port') }}</v-col>
<v-col dir="ltr">
{{ item.listen_port }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('in.tls') }}</v-col>
<v-col dir="ltr">
{{ Object.hasOwn(item,'tls') ? $t(item.tls?.enabled ? 'enable' : 'disable') : '-' }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('pages.clients') }}</v-col>
<v-col dir="ltr">
<v-tooltip activator="parent" dir="ltr" location="bottom" v-if="Object.hasOwn(item,'users')">
<span v-for="u in findInbounsUsers(item)">{{ u }}<br /></span>
</v-tooltip>
{{ Object.hasOwn(item,'users') ? item.users.length : '-' }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('online') }}</v-col>
<v-col dir="ltr">
<template v-if="onlines[index]">
<v-chip density="comfortable" size="small" color="success" variant="flat">{{ $t('online') }}</v-chip>
</template>
<template v-else>-</template>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions style="padding: 0;">
<v-btn icon="mdi-file-edit" @click="showModal(index)">
<v-icon />
<v-tooltip activator="parent" location="top" :text="$t('actions.edit')"></v-tooltip>
</v-btn>
<v-btn icon="mdi-file-remove" style="margin-inline-start:0;" color="warning" @click="delOverlay[index] = true">
<v-icon />
<v-tooltip activator="parent" location="top" :text="$t('actions.del')"></v-tooltip>
</v-btn>
<v-overlay
v-model="delOverlay[index]"
contained
class="align-center justify-center"
>
<v-card :title="$t('actions.del')" rounded="lg">
<v-divider></v-divider>
<v-card-text>{{ $t('confirm') }}</v-card-text>
<v-card-actions>
<v-btn color="error" variant="outlined" @click="delInbound(index)">{{ $t('yes') }}</v-btn>
<v-btn color="success" variant="outlined" @click="delOverlay[index] = false">{{ $t('no') }}</v-btn>
</v-card-actions>
</v-card>
</v-overlay>
<v-btn icon="mdi-chart-line" @click="showStats(item.tag)" />
</v-card-actions>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import Data from '@/store/modules/data'
import InboundVue from '@/layouts/modals/Inbound.vue'
import Stats from '@/layouts/modals/Stats.vue'
import { Config, V2rayApiStats } from '@/types/config'
import { computed, ref } from 'vue'
import { InTypes, Inbound, InboundWithUser, ShadowTLS, VLESS } from '@/types/inbounds'
import { Client } from '@/types/clients'
import { Link, LinkUtil } from '@/plugins/link'
const appConfig = computed((): Config => {
return <Config> Data().config
})
const inbounds = computed((): Inbound[] => {
return <Inbound[]> appConfig.value.inbounds
})
const clients = computed((): Client[] => {
return <Client[]> Data().clients
})
const onlines = computed(() => {
return Data().onlines.inbound ? inbounds.value.map(i => Data().onlines.inbound.includes(i.tag)) : []
})
const v2rayStats = computed((): V2rayApiStats => {
return <V2rayApiStats> appConfig.value.experimental.v2ray_api.stats
})
const modal = ref({
visible: false,
id: -1,
data: "",
stats: false,
})
let delOverlay = ref(new Array<boolean>)
const showModal = (id: number) => {
modal.value.id = id
modal.value.data = id == -1 ? '' : JSON.stringify(inbounds.value[id])
modal.value.stats = id == -1 ? false : v2rayStats.value.inbounds.includes(inbounds.value[id].tag)
modal.value.visible = true
}
const closeModal = () => {
modal.value.visible = false
}
const saveModal = (data:Inbound, stats: boolean) => {
// New or Edit
if (modal.value.id == -1) {
inbounds.value.push(data)
if (stats && data.tag.length>0) {
v2rayStats.value.inbounds.push(data.tag)
}
} else {
const oldTag = inbounds.value[modal.value.id].tag
const sIndex = v2rayStats.value.inbounds.findIndex(i => i == data.tag) // Find if new tag exists
if (oldTag != data.tag) {
v2rayStats.value.inbounds = v2rayStats.value.inbounds.filter(item => item != oldTag)
changeClientInboundsTag(oldTag,data.tag)
}
if (stats) {
// Add if dos not exist
if (data.tag.length>0 && sIndex == -1) v2rayStats.value.inbounds.push(data.tag)
} else {
// Delete if exists
if (sIndex != -1) v2rayStats.value.inbounds.splice(sIndex,1)
}
inbounds.value[modal.value.id] = data
}
// Set users
data = buildInboundsUsers(data)
// Update links
if (Object.hasOwn(data,'users')) updateLinks(data)
modal.value.visible = false
}
const updateLinks = (i: InboundWithUser) => {
if(i.users && i.users.length>0){
i.users.forEach((u:any) => {
const client = clients.value.find(c => u.username? c.name == u.username : c.name == u.name)
if (client){
const clientInbounds = <Inbound[]>inbounds.value.filter(i => client?.inbounds.split(',').includes(i.tag))
const newLinks = <Link[]>[]
clientInbounds.forEach(i =>{
const uri = LinkUtil.linkGenerator(client.name,i)
if (uri.length>0){
newLinks.push(<Link>{ type: 'local', remark: i.tag, uri: uri })
}
})
let links = client.links && client.links.length>0? <Link[]>JSON.parse(client.links) : <Link[]>[]
links = [...newLinks, ...links.filter(l => l.type != 'local')]
client.links = JSON.stringify(links)
}
})
}
}
const delInbound = (index: number) => {
const inb = inbounds.value[index]
inbounds.value.splice(index,1)
const tag = inb.tag
if (Object.hasOwn(inb,'users')){
const inbU = <InboundWithUser>inb
if (inbU.users && inbU.users.length>0){
inbU.users.forEach((u:any) => {
const c_index = clients.value.findIndex(c => u.username? u.username == c.name : u.user == c.name)
if (c_index != -1) {
const clientInbounds = clients.value[c_index].inbounds.split(',').filter((x:string) => x!=tag)
clients.value[c_index].inbounds = clientInbounds.join(',')
}
})
}
}
// Delete stats if exists and will be orphaned
const tagCounts = inbounds.value.filter(i => i.tag == inb.tag).length
const sIndex = v2rayStats.value.inbounds.findIndex(i => i == inb.tag)
if (tagCounts == 1 && sIndex != -1){
v2rayStats.value.inbounds.splice(sIndex,1)
}
if (index < Data().oldData.config.inbounds.length){
Data().delInbound(index)
}
delOverlay.value[index] = false
}
const buildInboundsUsers = (inbound:InboundWithUser):Inbound => {
const users = <any>[]
const inboundClients = clients.value.filter(c => c.enable && c.inbounds.split(',').includes(inbound.tag))
inboundClients.forEach(c => {
const clientConfig = JSON.parse(c.config)
// Remove flow in non tls VLESS
if (inbound.type == InTypes.VLESS) {
const vlessInbound = <VLESS>inbound
if (!vlessInbound.tls?.enabled) clientConfig["vless"].flow = undefined
}
users.push(clientConfig[inbound.type])
})
inbound.users = users
// Exceptions for Naive and ShadowTLSv3
if (users.length == 0){
if (inbound.type == InTypes.Naive){
inbound.users = <any>[{}]
} else {
if (inbound.type == InTypes.ShadowTLS){
const ssTls = <ShadowTLS>inbound
if (ssTls.version == 3) inbound.users = <any>[{}]
}
}
}
return <Inbound>inbound
}
const changeClientInboundsTag = (oldtag: string, newTag:string) => {
clients.value.forEach((c, c_index) => {
const inboundsArray = c.inbounds.split(',')
const inbound_index = inboundsArray.findIndex(i => i == oldtag)
if (inbound_index != -1) {
inboundsArray[inbound_index] = newTag
clients.value[c_index].inbounds = inboundsArray.join(',')
}
})
}
const findInbounsUsers = (inbound: InboundWithUser): string[] => {
if (inbound.users === null || !Array.isArray(inbound.users) || inbound.users.length == 0) return []
const users = inbound.users.map(user => "username" in user ? user.username : user.name)
return users
}
const stats = ref({
visible: false,
resource: "inbound",
tag: "",
})
const showStats = (tag: string) => {
stats.value.tag = tag
stats.value.visible = true
}
const closeStats = () => {
stats.value.visible = false
}
</script>
+86
View File
@@ -0,0 +1,86 @@
<template>
<v-container class="fill-height" style="margin-top: 100px;">
<v-row justify="center" align="center">
<v-col cols="12" sm="8" md="4">
<v-card>
<v-card-title class="headline">Login</v-card-title>
<v-card-text>
<v-form @submit.prevent="login" ref="form">
<v-text-field v-model="username" :label="$t('login.username')" :rules="usernameRules" required></v-text-field>
<v-text-field v-model="password" :label="$t('login.password')" :rules="passwordRules" type="password" required></v-text-field>
<v-btn :loading="loading" type="submit" color="primary" block class="mt-2">Login</v-btn>
</v-form>
<v-select
density="compact"
class="mt-2"
hide-details
variant="solo"
:items="languages"
v-model="$i18n.locale"
@update:modelValue="changeLocale">
<template v-slot:append>
<v-icon icon="mdi-theme-light-dark" @click="toggleTheme()"></v-icon>
</template>
</v-select>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts" setup>
import { ref } from "vue"
import { useLocale,useTheme } from 'vuetify'
import { i18n, languages } from '@/locales'
import { useRouter } from 'vue-router'
import HttpUtil from '@/plugins/httputil'
const theme = useTheme()
const locale = useLocale()
const darkMode = ref(localStorage.getItem('theme') == "dark")
const username = ref('')
const usernameRules = [
(value: string) => {
if (value?.length > 0) return true
return i18n.global.t('login.unRules')
},
]
const password = ref('')
const passwordRules = [
(value: string) => {
if (value?.length > 0) return true
return i18n.global.t('login.pwRules')
},
]
const loading = ref(false)
const router = useRouter()
const login = async () => {
if (username.value == '' || password.value == '') return
loading.value=true
const response = await HttpUtil.post('/api/login',{user: username.value, pass: password.value})
if(response.success){
setTimeout(() => {
loading.value=false
router.push('/')
}, 500)
} else {
loading.value=false
}
}
const changeLocale = (l: any) => {
locale.current.value = l ?? 'en'
localStorage.setItem('locale', locale.current.value)
}
const toggleTheme = () => {
darkMode.value = !darkMode.value
theme.global.name.value = darkMode.value ? "dark" : "light"
localStorage.setItem('theme', theme.global.name.value)
}
</script>
+34
View File
@@ -0,0 +1,34 @@
<template>
<v-row>
<v-col cols="12" sm="4" md="3" lg="2" v-for="(item, index) in <any[]>outbounds" :key="item.tag">
<v-card rounded="xl" elevation="5" min-width="200" :title="item.tag">
<v-card-subtitle>
<v-row>
<v-col>{{ item.type }}</v-col>
</v-row>
</v-card-subtitle>
<v-card-text>
<v-row>
<v-col>Server</v-col>
<v-col dir="ltr">
{{ (item.server ?? '') + ' ' + (item.server_port ?? '') }}
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import Data from '@/store/modules/data'
import { computed } from 'vue'
const appConfig = Data().config
const outbounds = computed((): any[] => {
if (!appConfig || !('outbounds' in appConfig) || !Array.isArray(appConfig.outbounds)) {
return []
}
return appConfig.outbounds
})
</script>
+62
View File
@@ -0,0 +1,62 @@
<template>
<v-row>
<v-col cols="12" sm="4" md="3" lg="2" v-for="(item, index) in <any[]>rules" :key="item.name">
<v-card rounded="xl" elevation="5" min-width="200" :title="index">
<v-card-text>
<v-row>
<v-col>Type</v-col>
<v-col dir="ltr">
{{ item.type }}
</v-col>
</v-row>
<v-row>
<v-col>Mode</v-col>
<v-col dir="ltr">
{{ item.mode }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('objects.outbound') }}</v-col>
<v-col dir="ltr">
{{ item.outbound }}
</v-col>
</v-row>
<v-row>
<v-col>{{ $t('pages.rules') }}</v-col>
<v-col dir="ltr">
{{ item.rules ? item.rules.length : 0 }}
</v-col>
</v-row>
<v-row>
<v-col>Invert</v-col>
<v-col dir="ltr">
{{ item.invert }}
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import Data from '@/store/modules/data'
import { computed, ref } from 'vue'
const appConfig = Data().config
const route = computed((): any => {
if (!appConfig || !('route' in appConfig)) {
return []
}
return appConfig.route
})
const rules = computed((): any[] => {
const data = route.value
if (!route || !('rules' in data) || !Array.isArray(data.rules)){
return []
}
return data.rules
})
</script>
+252
View File
@@ -0,0 +1,252 @@
<template>
<v-card :loading="loading">
<v-tabs
v-model="tab"
align-tabs="center"
bg-color="secondary"
show-arrows
>
<v-tab value="t1">{{ $t('setting.interface') }}</v-tab>
<v-tab value="t2">{{ $t('setting.sub') }}</v-tab>
<v-tab value="t3">Language</v-tab>
</v-tabs>
<v-card-text>
<v-row align="center" justify="center" style="margin-bottom: 10px;">
<v-col cols="auto">
<v-btn color="primary" @click="saveChanges" :loading="loading" :disabled="!stateChange">
{{ $t('actions.save') }}
</v-btn>
</v-col>
<v-col cols="auto">
<v-btn variant="outlined" color="warning" @click="restartApp" :loading="loading" :disabled="stateChange">
{{ $t('actions.restartApp') }}
</v-btn>
</v-col>
</v-row>
<v-window v-model="tab">
<v-window-item value="t1">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.webListen" :label="$t('setting.addr')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="webPort" :label="$t('setting.port')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.webDomain" :label="$t('setting.domain')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.webKeyFile" :label="$t('setting.sslKey')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.webCertFile" :label="$t('setting.sslCert')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
type="number"
v-model.number="sessionMaxAge"
:label="$t('setting.sessionAge')"
hide-details
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.timeLocation" :label="$t('setting.timeLoc')" hide-details></v-text-field>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="t2">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="subEncode" :label="$t('setting.subEncode')" hide-details />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-switch color="primary" v-model="subShowInfo" :label="$t('setting.subInfo')" hide-details />
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.subListen" :label="$t('setting.addr')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
type="number"
v-model="subPort"
min="1"
:label="$t('setting.port')"
hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.subKeyFile" :label="$t('setting.sslKey')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.subCertFile" :label="$t('setting.sslCert')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.subDomain" :label="$t('setting.domain')" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.subPath" :label="$t('setting.path')" hide-details></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
type="number"
v-model.number="subUpdates"
:label="$t('setting.update')"
hide-details
></v-text-field>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.subURI" :label="$t('setting.subUri')" hide-details></v-text-field>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="t3">
<v-row>
<v-col cols="12" sm="6" md="4">
<v-select
hide-details
label="Language"
:items="languages"
v-model="$i18n.locale"
@update:modelValue="changeLocale">
</v-select>
</v-col>
</v-row>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
</template>
<script lang="ts" setup>
import { useLocale } from "vuetify"
import { languages } from '@/locales'
import { Ref, computed, inject, onMounted, ref } from "vue"
import HttpUtils from "@/plugins/httputil"
import { FindDiff } from "@/plugins/utils"
const locale = useLocale()
const tab = ref("t1")
const loading:Ref = inject('loading')?? ref(false)
const oldSettings = ref({})
const settings = ref({
webListen: "",
webDomain: "",
webPort: "2095",
webCertFile: "",
webKeyFile: "",
sessionMaxAge: "0",
timeLocation: "Asia/Tehran",
subListen: "",
subPort: "2096",
subPath: "/sub/",
subDomain: "",
subCertFile: "",
subKeyFile: "",
subUpdates: "12",
subEncode: "true",
subShowInfo: "false",
subURI: "",
})
onMounted(async () => {loadData()})
const changeLocale = (l: any) => {
locale.current.value = l ?? 'en'
localStorage.setItem('locale', locale.current.value)
}
const loadData = async () => {
loading.value = true
const msg = await HttpUtils.get('/api/setting')
loading.value = false
if (msg.success) {
settings.value = msg.obj
oldSettings.value = { ...msg.obj }
}
}
const saveChanges = async () => {
loading.value = true
const diff = {
settings: JSON.stringify(FindDiff.Settings(settings.value,oldSettings.value)),
}
const msg = await HttpUtils.post('/api/save', diff)
if (msg.success) {
loadData()
}
loading.value = false
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const restartApp = async () => {
loading.value = true
const msg = await HttpUtils.post('/api/restartApp',{})
if (msg.success) {
const isTLS = settings.value.webCertFile !== "" || settings.value.webKeyFile !== ""
const url = buildURL(settings.value.webDomain,settings.value.webPort.toString(),isTLS)
await sleep(3000)
window.location.replace(url)
}
loading.value = false
}
const buildURL = (host: string, port: string, isTLS: boolean ) => {
if (!host || host.length == 0) host = window.location.hostname
if (!port || port.length == 0) port = window.location.port
const protocol = isTLS ? "https:" : "http:"
if (port === "" || (isTLS && port === "443") || (!isTLS && port === "80")) {
port = ""
} else {
port = `:${port}`
}
return `${protocol}//${host}${port}/settings`
}
const subEncode = computed({
get: () => { return settings.value.subEncode == "true" },
set: (v:boolean) => { settings.value.subEncode = v ? "true" : "false" }
})
const subShowInfo = computed({
get: () => { return settings.value.subShowInfo == "true" },
set: (v:boolean) => { settings.value.subShowInfo = v ? "true" : "false" }
})
const webPort = computed({
get: () => { return parseInt(settings.value.webPort) },
set: (v:number) => { settings.value.webPort = v.toString() }
})
const sessionMaxAge = computed({
get: () => { return parseInt(settings.value.sessionMaxAge) },
set: (v:number) => { settings.value.sessionMaxAge = v.toString() }
})
const subPort = computed({
get: () => { return parseInt(settings.value.subPort) },
set: (v:number) => { settings.value.subPort = v.toString() }
})
const subUpdates = computed({
get: () => { return parseInt(settings.value.subUpdates) },
set: (v:number) => { settings.value.subUpdates = v.toString() }
})
const stateChange = computed(() => {
return !FindDiff.deepCompare(settings.value,oldSettings.value)
})
</script>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": [
"src/*"
]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }],
"exclude": ["node_modules"],
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+49
View File
@@ -0,0 +1,49 @@
// Plugins
import vue from '@vitejs/plugin-vue'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
// Utilities
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue({
template: { transformAssetUrls },
}),
vuetify({
autoImport: true,
styles: {
configFile: 'src/styles/settings.scss',
},
})
],
build: {
manifest: false,
outDir: 'dist',
chunkSizeWarningLimit: 1600,
rollupOptions: {
output: {
inlineDynamicImports: true,
entryFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name].[ext]',
},
}
},
define: { 'process.env': {} },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:2095',
changeOrigin: true,
},
},
}
})