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
+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>