Rename client app to Harbor Connect and refresh state animations
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-07-11 16:24:28 +03:00
parent edad26d978
commit b53cd08dcc
4 changed files with 70 additions and 23 deletions

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
))} ))}

View File

@@ -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;
} }
} }