Compare commits

...

16 Commits

Author SHA1 Message Date
Alireza Ahmadi 21add1f3ce v0.0.4 2024-05-23 18:52:55 +02:00
Alireza Ahmadi 9968f3885f small fixes 2024-05-23 18:38:50 +02:00
Alireza Ahmadi 2ac13ef8f4 sing-box v1.8.14 2024-05-23 12:01:36 +02:00
Alireza Ahmadi 4900c14295 fix session key 2024-05-23 12:01:04 +02:00
Alireza Ahmadi 55a6d78114 avoid duplicate api call 2024-05-23 12:00:37 +02:00
Alireza Ahmadi caa115bbe3 http transmition interoperability with xray links 2024-05-23 12:00:13 +02:00
Alireza Ahmadi e3be3be9d9 fix users config in non-user based protocols 2024-05-23 11:59:28 +02:00
Alireza Ahmadi 988675a7a7 fix editing in/out tag 2024-05-23 11:57:51 +02:00
Alireza Ahmadi 458f0c20da fix numbers in settings 2024-05-23 11:56:55 +02:00
Alireza Ahmadi f8fbc3c329 fix typo in outbound port 2024-05-23 11:56:31 +02:00
Alireza Ahmadi 89bc3b5b23 fix gauge jumping on update 2024-05-23 11:55:33 +02:00
Alireza Ahmadi edfe0c86e7 [hy2] optional masquerade 2024-05-23 11:55:05 +02:00
Alireza Ahmadi 6865c8b49d fix panel ssl config 2024-05-23 11:54:27 +02:00
Alireza Ahmadi 07947c9665 update frontend 2024-05-23 11:53:27 +02:00
Alireza Ahmadi 09616b6fac update github workflow 2024-05-22 17:51:50 +02:00
Alireza Ahmadi 15105710bc update docker 2024-05-22 17:51:18 +02:00
18 changed files with 699 additions and 1975 deletions
+6 -10
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
sudo apt-get update && sudo apt-get install upx -yq sudo apt-get update
if [ "${{ matrix.platform }}" == "arm64" ]; then if [ "${{ matrix.platform }}" == "arm64" ]; then
sudo apt install gcc-aarch64-linux-gnu sudo apt install gcc-aarch64-linux-gnu
elif [ "${{ matrix.platform }}" == "armv7" ]; then elif [ "${{ matrix.platform }}" == "armv7" ]; then
@@ -69,22 +69,18 @@ jobs:
fi fi
#### Build Sing-Box #### Build Sing-Box
git clone -b v1.8.13 https://github.com/SagerNet/sing-box export VERSION=v1.8.14
git clone -b $VERSION https://github.com/SagerNet/sing-box
cd sing-box cd sing-box
go build -v -gcflags=all="-l -B -C" -mod=mod -trimpath \ go build -tags with_quic,with_grpc,with_wireguard,with_ech,with_utls,with_reality_server,with_acme,with_v2ray_api,with_clash_api,with_gvisor \
-ldflags "-s -w -buildid= -extldflags '-static'" -a \ -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${VERSION}' -s -w -buildid=" \
-tags='netgo osusergo static_build with_quic with_grpc with_wireguard with_ech with_utls with_reality_server with_acme with_v2ray_api with_clash_api with_gvisor' \
-o sing-box ./cmd/sing-box -o sing-box ./cmd/sing-box
upx --ultra-brute -9 -v --lzma --best --force sing-box
cd .. cd ..
### Build s-ui ### Build s-ui
cd backend cd backend
go build -v -gcflags=all="-l -B -C" -mod=mod -trimpath \ go build -o ../sui main.go
-ldflags "-s -w -buildid= -extldflags '-static'" -a -tags='netgo osusergo static_build sqlite_omit_load_extension' \
-o ../sui main.go
cd .. cd ..
upx --ultra-brute -9 -v --lzma --best --force sui
mkdir s-ui mkdir s-ui
cp sui s-ui/ cp sui s-ui/
+5 -4
View File
@@ -3,17 +3,18 @@ WORKDIR /app
COPY frontend/ ./ COPY frontend/ ./
RUN npm install && npm run build RUN npm install && npm run build
FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS backend-builder FROM golang:1.22-alpine AS backend-builder
WORKDIR /app WORKDIR /app
ARG TARGETARCH ARG TARGETARCH
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE" ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
ENV CGO_ENABLED=1 ENV CGO_ENABLED=1
RUN apk --no-cache --update add build-base gcc wget unzip ENV GOARCH=$TARGETARCH
RUN apk update && apk --no-cache --update add build-base gcc wget unzip
COPY backend/ ./ COPY backend/ ./
COPY --from=front-builder /app/dist/ /app/web/html/ COPY --from=front-builder /app/dist/ /app/web/html/
RUN go build -o sui main.go RUN go build -ldflags="-w -s" -o sui main.go
FROM --platform=$BUILDPLATFORM alpine FROM --platform=$TARGETPLATFORM alpine
LABEL org.opencontainers.image.authors="alireza7@gmail.com" LABEL org.opencontainers.image.authors="alireza7@gmail.com"
ENV TZ=Asia/Tehran ENV TZ=Asia/Tehran
WORKDIR /app WORKDIR /app
+1 -1
View File
@@ -1 +1 @@
0.0.3 0.0.4
+1
View File
@@ -22,4 +22,5 @@ func (s *DelStatsJob) Run() {
logger.Warning("Deleting old statistics failed: ", err) logger.Warning("Deleting old statistics failed: ", err)
return return
} }
logger.Debug("Stats older than ", s.trafficAge, " days were deleted")
} }
+7 -7
View File
@@ -18,7 +18,7 @@ var defaultValueMap = map[string]string{
"webListen": "", "webListen": "",
"webDomain": "", "webDomain": "",
"webPort": "2095", "webPort": "2095",
"webSecret": common.Random(32), "secret": common.Random(32),
"webCertFile": "", "webCertFile": "",
"webKeyFile": "", "webKeyFile": "",
"webPath": "/app/", "webPath": "/app/",
@@ -191,11 +191,11 @@ func (s *SettingService) SetWebPath(webPath string) error {
} }
func (s *SettingService) GetSecret() ([]byte, error) { func (s *SettingService) GetSecret() ([]byte, error) {
secret, err := s.getString("webSecret") secret, err := s.getString("secret")
if secret == defaultValueMap["webSecret"] { if secret == defaultValueMap["secret"] {
err := s.saveSetting("webSecret", secret) err := s.saveSetting("secret", secret)
if err != nil { if err != nil {
logger.Warning("save webSecret failed:", err) logger.Warning("save secret failed:", err)
} }
} }
return []byte(secret), err return []byte(secret), err
@@ -318,10 +318,10 @@ func (s *SettingService) Save(tx *gorm.DB, changes []model.Changes) error {
json.Unmarshal(change.Obj, &obj) json.Unmarshal(change.Obj, &obj)
// Secure file existance check // Secure file existance check
if key == "webCertFile" || if obj != "" && (key == "webCertFile" ||
key == "webKeyFile" || key == "webKeyFile" ||
key == "subCertFile" || key == "subCertFile" ||
key == "subKeyFile" { key == "subKeyFile") {
err = s.fileExists(obj) err = s.fileExists(obj)
if err != nil { if err != nil {
return common.NewError(" -> ", obj, " is not exists") return common.NewError(" -> ", obj, " is not exists")
+16 -3
View File
@@ -1,13 +1,26 @@
package common package common
import "math/rand" import (
"math/rand"
"time"
)
var allSeq [62]rune var (
allSeq []rune
rnd = rand.New(rand.NewSource(time.Now().UnixNano()))
)
func init() {
chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
for _, char := range chars {
allSeq = append(allSeq, char)
}
}
func Random(n int) string { func Random(n int) string {
runes := make([]rune, n) runes := make([]rune, n)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
runes[i] = allSeq[rand.Intn(len(allSeq))] runes[i] = allSeq[rnd.Intn(len(allSeq))]
} }
return string(runes) return string(runes)
} }
+2 -2
View File
@@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS singbox-builder
LABEL maintainer="Alireza <alireza7@gmail.com>" LABEL maintainer="Alireza <alireza7@gmail.com>"
WORKDIR /app WORKDIR /app
ARG TARGETOS TARGETARCH ARG TARGETOS TARGETARCH
ARG SINGBOX_VER=v1.8.10 ARG SINGBOX_VER=v1.8.13
ARG SINGBOX_TAGS="with_quic,with_grpc,with_wireguard,with_ech,with_utls,with_reality_server,with_acme,with_v2ray_api,with_clash_api,with_gvisor" ARG SINGBOX_TAGS="with_quic,with_grpc,with_wireguard,with_ech,with_utls,with_reality_server,with_acme,with_v2ray_api,with_clash_api,with_gvisor"
ARG GOPROXY="" ARG GOPROXY=""
ENV GOPROXY ${GOPROXY} ENV GOPROXY ${GOPROXY}
@@ -18,7 +18,7 @@ RUN set -ex \
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$SINGBOX_VER\" -s -w -buildid=" \ -ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$SINGBOX_VER\" -s -w -buildid=" \
./cmd/sing-box ./cmd/sing-box
FROM --platform=$BUILDPLATFORM alpine FROM --platform=$TARGETPLATFORM alpine
LABEL maintainer="Alireza <alireza7@gmail.com>" LABEL maintainer="Alireza <alireza7@gmail.com>"
ENV TZ=Asia/Tehran ENV TZ=Asia/Tehran
WORKDIR /app WORKDIR /app
-2
View File
@@ -1,6 +1,4 @@
--- ---
version: "3"
services: services:
s-ui: s-ui:
image: alireza7/s-ui image: alireza7/s-ui
+560 -1900
View File
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -3,41 +3,41 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore" "lint": "eslint . --fix --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@mdi/font": "7.0.96", "@mdi/font": "7.0.96",
"axios": "^1.6.5", "axios": "^1.7.2",
"chart.js": "^4.4.1", "chart.js": "^4.4.3",
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"core-js": "^3.29.0", "core-js": "^3.37.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"qrcode.vue": "^3.4.1", "qrcode.vue": "^3.4.1",
"roboto-fontface": "*", "roboto-fontface": "^0.10.0",
"vue": "^3.2.0", "vue": "^3.2.0",
"vue-chartjs": "^5.3.0", "vue-chartjs": "^5.3.1",
"vue-i18n": "^9.8.0", "vue-i18n": "^9.13.1",
"vue-router": "^4.0.0", "vue-router": "^4.3.2",
"vue3-persian-datetime-picker": "^1.2.2", "vue3-persian-datetime-picker": "^1.2.2",
"vuetify": "^3.0.0" "vuetify": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/types": "^7.21.4", "@babel/types": "^7.24.5",
"@types/node": "^18.15.0", "@types/node": "^18.19.33",
"@vitejs/plugin-vue": "^4.0.0", "@vitejs/plugin-vue": "^4.6.2",
"@vue/eslint-config-typescript": "^11.0.0", "@vue/eslint-config-typescript": "^11.0.3",
"eslint": "^8.22.0", "eslint": "^8.57.0",
"eslint-plugin-vue": "^9.3.0", "eslint-plugin-vue": "^9.26.0",
"material-design-icons-iconfont": "^6.7.0", "material-design-icons-iconfont": "^6.7.0",
"sass": "^1.60.0", "sass": "^1.77.2",
"typescript": "^5.0.0", "typescript": "^5.4.5",
"unplugin-fonts": "^1.0.3", "unplugin-fonts": "^1.1.1",
"vite": "^4.5.3", "vite": "^4.5.3",
"vite-plugin-vuetify": "^1.0.0", "vite-plugin-vuetify": "^1.0.2",
"vue-tsc": "^1.2.0" "vue-tsc": "^1.8.27"
} }
} }
@@ -1,7 +1,7 @@
<template> <template>
<v-card subtitle="Hysteria2"> <v-card subtitle="Hysteria2">
<v-row v-if="direction == 'in'"> <v-row v-if="direction == 'in'">
<v-col cols="12" sm="6" md="4"> <v-col cols="12" sm="6" md="4" v-if="data.masquerade != undefined">
<v-text-field <v-text-field
label="HTTP3 server on auth fail" label="HTTP3 server on auth fail"
hide-details hide-details
@@ -46,7 +46,7 @@
</v-text-field> </v-text-field>
</v-col> </v-col>
</v-row> </v-row>
<v-row v-if="data.obfs"> <v-row v-if="data.obfs != undefined">
<v-col cols="12" sm="6" md="4"> <v-col cols="12" sm="6" md="4">
<v-text-field <v-text-field
:label="$t('types.hy.obfs')" :label="$t('types.hy.obfs')"
@@ -66,6 +66,9 @@
<v-list-item> <v-list-item>
<v-switch v-model="optionObfs" color="primary" :label="$t('types.hy.obfs')" hide-details></v-switch> <v-switch v-model="optionObfs" color="primary" :label="$t('types.hy.obfs')" hide-details></v-switch>
</v-list-item> </v-list-item>
<v-list-item>
<v-switch v-model="optionMasq" color="primary" label="Masquerade" hide-details></v-switch>
</v-list-item>
</v-list> </v-list>
</v-card> </v-card>
</v-menu> </v-menu>
@@ -95,6 +98,10 @@ export default {
optionObfs: { optionObfs: {
get(): boolean { return this.$props.data.obfs != undefined }, get(): boolean { return this.$props.data.obfs != undefined },
set(v:boolean) { this.$props.data.obfs = v ? { type: "salamander", password: "" } : undefined } set(v:boolean) { this.$props.data.obfs = v ? { type: "salamander", password: "" } : undefined }
},
optionMasq: {
get(): boolean { return this.$props.data.masquerade != undefined },
set(v:boolean) { this.$props.data.masquerade = v ? "" : undefined }
} }
}, },
components: { Network } components: { Network }
+1 -2
View File
@@ -49,8 +49,7 @@ const gaugeColor = computed(() => {
background: `rgb(var(--v-theme-${gaugeColor}))` background: `rgb(var(--v-theme-${gaugeColor}))`
}"> }">
</div> </div>
<span class="gauge__cover" dir="ltr" v-html="data.text"> <div class="gauge__cover"><span dir="ltr" v-html="data.text"></span></div>
</span>
</div> </div>
</div> </div>
</template> </template>
+1 -1
View File
@@ -30,7 +30,7 @@
</v-col> </v-col>
<v-col cols="12" sm="6" md="4"> <v-col cols="12" sm="6" md="4">
<v-text-field <v-text-field
:label="$t('out.addr')" :label="$t('out.port')"
type="number" type="number"
min="0" min="0"
hide-details hide-details
+38 -1
View File
@@ -1,12 +1,29 @@
import axios from 'axios' import axios from 'axios';
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8' axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
axios.defaults.baseURL = "./" axios.defaults.baseURL = "./"
const pendingRequests = new Map()
axios.interceptors.request.use( axios.interceptors.request.use(
(config) => { (config) => {
// Generate a unique key for the request
const requestKey = `${config.method}:${config.url}`
// Check if there is already a pending request with the same key
if (pendingRequests.has(requestKey)) {
const cancelSource = pendingRequests.get(requestKey)
cancelSource.cancel('Duplicate request cancelled')
}
// Create a new cancel token for the request
const cancelSource = axios.CancelToken.source()
config.cancelToken = cancelSource.token
// Store the cancel token in the pending requests map
pendingRequests.set(requestKey, cancelSource)
if (config.data instanceof FormData) { if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data' config.headers['Content-Type'] = 'multipart/form-data'
} }
@@ -15,6 +32,26 @@ axios.interceptors.request.use(
(error) => Promise.reject(error), (error) => Promise.reject(error),
) )
axios.interceptors.response.use(
(response) => {
// Remove the request from the pending requests map
const requestKey = `${response.config.method}:${response.config.url}`
pendingRequests.delete(requestKey)
return response
},
(error) => {
if (axios.isCancel(error)) {
// Handle duplicate request cancellation here if needed
console.warn(error.message)
} else {
// Remove the request from the pending requests map on error
const requestKey = `${error.config.method}:${error.config.url}`
pendingRequests.delete(requestKey)
}
return Promise.reject(error)
}
);
const api = axios.create() const api = axios.create()
export default api export default api
+3
View File
@@ -140,10 +140,12 @@ export namespace LinkUtil {
host: <string|null>'', host: <string|null>'',
path: <string|null>'', path: <string|null>'',
serviceName: <string|null>'', serviceName: <string|null>'',
type: <string|null>null,
} }
switch (t.type){ switch (t.type){
case TrspTypes.HTTP: case TrspTypes.HTTP:
const th = <HTTP>t const th = <HTTP>t
params.type = 'http'
params.host = th.host?.join(',')?? null params.host = th.host?.join(',')?? null
params.path = th.path?? null params.path = th.path?? null
break break
@@ -225,6 +227,7 @@ export namespace LinkUtil {
host: tParams.host, host: tParams.host,
id: u?.uuid, id: u?.uuid,
net: transport?.type?? 'tcp', net: transport?.type?? 'tcp',
type: transport?.type == 'http' ? 'http' : undefined,
path: tParams.path, path: tParams.path,
port: inbound.listen_port, port: inbound.listen_port,
ps: inbound.tag, ps: inbound.tag,
+9 -5
View File
@@ -158,7 +158,9 @@ const closeModal = () => {
modal.value.visible = false modal.value.visible = false
} }
const saveModal = (data:Inbound, stats: boolean) => { const saveModal = (data:Inbound, stats: boolean) => {
if (inbounds.value.findIndex(c => c.tag == data.tag) != modal.value.id) { // Check duplicate tag
const oldTag = modal.value.id != -1 ? inbounds.value[modal.value.id].tag : null
if (data.tag != oldTag && inTags.value.includes(data.tag)) {
const sb = Message() const sb = Message()
sb.showMessage(i18n.global.t('error.dplData') + ': ' + i18n.global.t('objects.tag') ,'error', 5000) sb.showMessage(i18n.global.t('error.dplData') + ': ' + i18n.global.t('objects.tag') ,'error', 5000)
return return
@@ -188,10 +190,12 @@ const saveModal = (data:Inbound, stats: boolean) => {
inbounds.value[modal.value.id] = data inbounds.value[modal.value.id] = data
} }
// Set users if (Object.hasOwn(data,'users')) {
data = buildInboundsUsers(data) // Set users
// Update links data = buildInboundsUsers(data)
if (Object.hasOwn(data,'users')) updateLinks(data) // Update links
updateLinks(data)
}
modal.value.visible = false modal.value.visible = false
} }
const updateLinks = (i: InboundWithUser) => { const updateLinks = (i: InboundWithUser) => {
+3 -1
View File
@@ -139,7 +139,9 @@ const closeModal = () => {
modal.value.visible = false modal.value.visible = false
} }
const saveModal = (data:Outbound, stats: boolean) => { const saveModal = (data:Outbound, stats: boolean) => {
if (outbounds.value.findIndex(c => c.tag == data.tag) != modal.value.id) { // Check duplicate tag
const oldTag = modal.value.id != -1 ? outbounds.value[modal.value.id].tag : null
if (data.tag != oldTag && outboundTags.value.includes(data.tag)) {
const sb = Message() const sb = Message()
sb.showMessage(i18n.global.t('error.dplData') + ': ' + i18n.global.t('objects.tag') ,'error', 5000) sb.showMessage(i18n.global.t('error.dplData') + ': ' + i18n.global.t('objects.tag') ,'error', 5000)
return return
+16 -13
View File
@@ -30,7 +30,7 @@
<v-text-field v-model="settings.webListen" :label="$t('setting.addr')" hide-details></v-text-field> <v-text-field v-model="settings.webListen" :label="$t('setting.addr')" hide-details></v-text-field>
</v-col> </v-col>
<v-col cols="12" sm="6" md="4"> <v-col cols="12" sm="6" md="4">
<v-text-field v-model="webPort" :label="$t('setting.port')" hide-details></v-text-field> <v-text-field v-model.number="webPort" min="1" type="number" :label="$t('setting.port')" hide-details></v-text-field>
</v-col> </v-col>
<v-col cols="12" sm="6" md="4"> <v-col cols="12" sm="6" md="4">
<v-text-field v-model="settings.webPath" :label="$t('setting.webPath')" hide-details></v-text-field> <v-text-field v-model="settings.webPath" :label="$t('setting.webPath')" hide-details></v-text-field>
@@ -51,8 +51,9 @@
<v-text-field <v-text-field
type="number" type="number"
v-model.number="sessionMaxAge" v-model.number="sessionMaxAge"
min="0"
:label="$t('setting.sessionAge')" :label="$t('setting.sessionAge')"
:suffix="$t('date.h')" :suffix="$t('date.m')"
hide-details hide-details
></v-text-field> ></v-text-field>
</v-col> </v-col>
@@ -60,6 +61,7 @@
<v-text-field <v-text-field
type="number" type="number"
v-model.number="trafficAge" v-model.number="trafficAge"
min="0"
:label="$t('setting.trafficAge')" :label="$t('setting.trafficAge')"
:suffix="$t('date.d')" :suffix="$t('date.d')"
hide-details hide-details
@@ -87,7 +89,7 @@
<v-col cols="12" sm="6" md="4"> <v-col cols="12" sm="6" md="4">
<v-text-field <v-text-field
type="number" type="number"
v-model="subPort" v-model.number="subPort"
min="1" min="1"
:label="$t('setting.port')" :label="$t('setting.port')"
hide-details></v-text-field> hide-details></v-text-field>
@@ -114,6 +116,7 @@
<v-text-field <v-text-field
type="number" type="number"
v-model.number="subUpdates" v-model.number="subUpdates"
min="0"
:label="$t('setting.update')" :label="$t('setting.update')"
hide-details hide-details
></v-text-field> ></v-text-field>
@@ -248,28 +251,28 @@ const subShowInfo = computed({
}) })
const webPort = computed({ const webPort = computed({
get: () => { return parseInt(settings.value.webPort) }, get: () => { return settings.value.webPort.length>0 ? parseInt(settings.value.webPort) : 2095 },
set: (v:number) => { settings.value.webPort = v.toString() } set: (v:number) => { settings.value.webPort = v>0 ? v.toString() : "2095" }
}) })
const sessionMaxAge = computed({ const sessionMaxAge = computed({
get: () => { return parseInt(settings.value.sessionMaxAge) }, get: () => { return settings.value.sessionMaxAge.length>0 ? parseInt(settings.value.sessionMaxAge) : 0 },
set: (v:number) => { settings.value.sessionMaxAge = v.toString() } set: (v:number) => { settings.value.sessionMaxAge = v>0 ? v.toString() : "0" }
}) })
const trafficAge = computed({ const trafficAge = computed({
get: () => { return parseInt(settings.value.trafficAge) }, get: () => { return settings.value.trafficAge.length>0 ? parseInt(settings.value.trafficAge) : 0 },
set: (v:number) => { settings.value.trafficAge = v.toString() } set: (v:number) => { settings.value.trafficAge = v>0 ? v.toString() : "0" }
}) })
const subPort = computed({ const subPort = computed({
get: () => { return parseInt(settings.value.subPort) }, get: () => { return settings.value.subPort.length>0 ? parseInt(settings.value.subPort) : 2096 },
set: (v:number) => { settings.value.subPort = v.toString() } set: (v:number) => { settings.value.subPort = v>0 ? v.toString() : "2096" }
}) })
const subUpdates = computed({ const subUpdates = computed({
get: () => { return parseInt(settings.value.subUpdates) }, get: () => { return settings.value.subUpdates.length>0 ? parseInt(settings.value.subUpdates) : 12 },
set: (v:number) => { settings.value.subUpdates = v.toString() } set: (v:number) => { settings.value.subUpdates = v>0 ? v.toString() : "12" }
}) })
const stateChange = computed(() => { const stateChange = computed(() => {