Rename client app to Harbor Connect and refresh state animations
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
vpn-proxy-client:
|
harbor-connect:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.client
|
dockerfile: Dockerfile.client
|
||||||
args:
|
args:
|
||||||
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||||
container_name: vpn-proxy-client
|
container_name: harbor-connect
|
||||||
environment:
|
environment:
|
||||||
APP_MODE: client
|
APP_MODE: client
|
||||||
PORT: ${PORT:-3456}
|
PORT: ${PORT:-3456}
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ COMPOSE_FILE="docker-compose.client.yml"
|
|||||||
DEFAULT_PROXY_PORT="8082"
|
DEFAULT_PROXY_PORT="8082"
|
||||||
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||||
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||||
CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
CLIENT_CONTAINER_NAME="harbor-connect"
|
||||||
|
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||||
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
|
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[vpn-proxy-client] %s\n' "$*"
|
printf '[harbor-connect] %s\n' "$*"
|
||||||
}
|
}
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
printf '[vpn-proxy-client] error: %s\n' "$*" >&2
|
printf '[harbor-connect] error: %s\n' "$*" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ published_port_conflicts() {
|
|||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[ -n "$line" ] || continue
|
[ -n "$line" ] || continue
|
||||||
case "$line" in
|
case "$line" in
|
||||||
"${CLIENT_CONTAINER_NAME}"$'\t'*) ;;
|
"${CLIENT_CONTAINER_NAME}"$'\t'*|"${LEGACY_CLIENT_CONTAINER_NAME}"$'\t'*) ;;
|
||||||
*) printf '%s\n' "$line" ;;
|
*) printf '%s\n' "$line" ;;
|
||||||
esac
|
esac
|
||||||
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
|
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
|
||||||
@@ -86,7 +87,7 @@ assert_proxy_port_available() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '[vpn-proxy-client] proxy port %s is already used:\n%s\n' \
|
printf '[harbor-connect] proxy port %s is already used:\n%s\n' \
|
||||||
"$port" "$conflicts" >&2
|
"$port" "$conflicts" >&2
|
||||||
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
|
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ assert_single_port_available() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '[vpn-proxy-client] %s port %s is already used:\n%s\n' \
|
printf '[harbor-connect] %s port %s is already used:\n%s\n' \
|
||||||
"$label" "$port" "$conflicts" >&2
|
"$label" "$port" "$conflicts" >&2
|
||||||
die "choose another ${label} port or stop the conflicting container"
|
die "choose another ${label} port or stop the conflicting container"
|
||||||
}
|
}
|
||||||
@@ -168,11 +169,11 @@ wait_for_client_ui() {
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
printf '\n[vpn-proxy-client] client did not become ready at %s\n' "$ui_url" >&2
|
printf '\n[harbor-connect] client did not become ready at %s\n' "$ui_url" >&2
|
||||||
printf '[vpn-proxy-client] docker compose status:\n' >&2
|
printf '[harbor-connect] docker compose status:\n' >&2
|
||||||
docker compose -f "$COMPOSE_FILE" ps >&2 || true
|
docker compose -f "$COMPOSE_FILE" ps >&2 || true
|
||||||
printf '\n[vpn-proxy-client] recent service logs:\n' >&2
|
printf '\n[harbor-connect] recent service logs:\n' >&2
|
||||||
docker compose -f "$COMPOSE_FILE" logs --tail=120 vpn-proxy-client >&2 || true
|
docker compose -f "$COMPOSE_FILE" logs --tail=120 harbor-connect >&2 || true
|
||||||
die "client UI is not ready; see Docker status and logs above"
|
die "client UI is not ready; see Docker status and logs above"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,12 +300,12 @@ log "proxy port: 127.0.0.1:${PROXY_PORT}"
|
|||||||
install_network_monitor
|
install_network_monitor
|
||||||
|
|
||||||
log "building and starting Docker client"
|
log "building and starting Docker client"
|
||||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
||||||
wait_for_client_ui
|
wait_for_client_ui
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
VPN Proxy Client is running.
|
Harbor Connect is running.
|
||||||
|
|
||||||
UI:
|
UI:
|
||||||
http://127.0.0.1:${UI_PORT}
|
http://127.0.0.1:${UI_PORT}
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ function DurationPart({ name, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AnimatedSeconds({ value, padded = true }) {
|
||||||
|
return String(value).padStart(padded ? 2 : 1, '0').split('').map((digit, index) => (
|
||||||
|
<span className="client-duration-second-digit" key={`${index}-${digit}`}>{digit}</span>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGatewayAuto }) {
|
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGatewayAuto }) {
|
||||||
const [modeAnimating, setModeAnimating] = useState(false);
|
const [modeAnimating, setModeAnimating] = useState(false);
|
||||||
const stopModeAnimationRef = useRef(false);
|
const stopModeAnimationRef = useRef(false);
|
||||||
@@ -213,6 +219,9 @@ export function ClientOverviewPage({
|
|||||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
const usage = subscriptionUsage(state?.userInfo);
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
||||||
|
const connectionTitle = connected
|
||||||
|
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
||||||
|
: 'Подключение выключено';
|
||||||
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||||||
const hasUsage = Boolean(
|
const hasUsage = Boolean(
|
||||||
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||||||
@@ -513,10 +522,10 @@ export function ClientOverviewPage({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="client-state-copy" aria-live="polite">
|
<div className="client-state-copy" aria-live="polite">
|
||||||
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
|
||||||
{connected
|
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
|
||||||
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
|
||||||
: 'Подключение выключено'}
|
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="client-state-detail">
|
<div className="client-state-detail">
|
||||||
{connected ? (
|
{connected ? (
|
||||||
@@ -534,7 +543,7 @@ export function ClientOverviewPage({
|
|||||||
>
|
>
|
||||||
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
|
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
|
||||||
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
|
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
|
||||||
:<DurationPart name="seconds-value" key={`digital-${duration.seconds.value}`}>{String(duration.seconds.value).padStart(2, '0')}</DurationPart>
|
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
|
||||||
</time>
|
</time>
|
||||||
<time
|
<time
|
||||||
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
|
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
|
||||||
@@ -549,7 +558,9 @@ export function ClientOverviewPage({
|
|||||||
.filter(([name, part]) => part.value || name === 'seconds')
|
.filter(([name, part]) => part.value || name === 'seconds')
|
||||||
.map(([name, part]) => (
|
.map(([name, part]) => (
|
||||||
<span className="client-duration-unit" data-unit={name} key={name}>
|
<span className="client-duration-unit" data-unit={name} key={name}>
|
||||||
<DurationPart name={`${name}-value`} key={`${name}-${name === 'seconds' ? part.value : 'value'}`}>{part.value}</DurationPart>{' '}
|
<DurationPart name={`${name}-value`}>{name === 'seconds'
|
||||||
|
? <AnimatedSeconds value={part.value} padded={false} />
|
||||||
|
: part.value}</DurationPart>{' '}
|
||||||
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -921,6 +921,26 @@ p {
|
|||||||
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-connection-title {
|
||||||
|
display: grid;
|
||||||
|
min-height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-connection-title > span {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(5px);
|
||||||
|
transform: scale(0.97);
|
||||||
|
transition: opacity 480ms ease, filter 620ms cubic-bezier(0.16, 1, 0.3, 1), transform 620ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-connection-title > .is-active {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
.client-state-detail {
|
.client-state-detail {
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -978,12 +998,24 @@ p {
|
|||||||
|
|
||||||
.client-duration-seconds-value {
|
.client-duration-seconds-value {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: client-second-tick 380ms cubic-bezier(0.16, 1, 0.3, 1);
|
}
|
||||||
|
|
||||||
|
.client-duration-second-digit {
|
||||||
|
display: inline-block;
|
||||||
|
animation: client-second-tick 520ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes client-second-tick {
|
@keyframes client-second-tick {
|
||||||
from { opacity: 0.28; filter: blur(3px); transform: scale(0.9); }
|
from {
|
||||||
to { opacity: 1; filter: blur(0); transform: scale(1); }
|
opacity: 0.82;
|
||||||
|
filter: blur(1px);
|
||||||
|
text-shadow: 0 0 8px color-mix(in oklch, var(--client-accent) 52%, transparent);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
text-shadow: 0 0 0 transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-duration-toggle {
|
.client-duration-toggle {
|
||||||
@@ -1912,6 +1944,7 @@ p {
|
|||||||
.client-copy-feedback,
|
.client-copy-feedback,
|
||||||
.client-duration,
|
.client-duration,
|
||||||
.client-duration-seconds-value,
|
.client-duration-seconds-value,
|
||||||
|
.client-duration-second-digit,
|
||||||
.client-instructions,
|
.client-instructions,
|
||||||
.client-instructions-toggle,
|
.client-instructions-toggle,
|
||||||
.client-instructions-toggle svg,
|
.client-instructions-toggle svg,
|
||||||
@@ -1961,7 +1994,9 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-state-copy h2,
|
.client-state-copy h2,
|
||||||
|
.client-connection-title > span,
|
||||||
.client-state-detail > * {
|
.client-state-detail > * {
|
||||||
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user