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:
vpn-proxy-client:
harbor-connect:
build:
context: .
dockerfile: Dockerfile.client
args:
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
container_name: vpn-proxy-client
container_name: harbor-connect
environment:
APP_MODE: client
PORT: ${PORT:-3456}

View File

@@ -8,15 +8,16 @@ COMPOSE_FILE="docker-compose.client.yml"
DEFAULT_PROXY_PORT="8082"
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_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"
log() {
printf '[vpn-proxy-client] %s\n' "$*"
printf '[harbor-connect] %s\n' "$*"
}
die() {
printf '[vpn-proxy-client] error: %s\n' "$*" >&2
printf '[harbor-connect] error: %s\n' "$*" >&2
exit 1
}
@@ -67,7 +68,7 @@ published_port_conflicts() {
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
"${CLIENT_CONTAINER_NAME}"$'\t'*) ;;
"${CLIENT_CONTAINER_NAME}"$'\t'*|"${LEGACY_CLIENT_CONTAINER_NAME}"$'\t'*) ;;
*) printf '%s\n' "$line" ;;
esac
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
@@ -86,7 +87,7 @@ assert_proxy_port_available() {
return 0
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
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
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
die "choose another ${label} port or stop the conflicting container"
}
@@ -168,11 +169,11 @@ wait_for_client_ui() {
sleep 1
done
printf '\n[vpn-proxy-client] client did not become ready at %s\n' "$ui_url" >&2
printf '[vpn-proxy-client] docker compose status:\n' >&2
printf '\n[harbor-connect] client did not become ready at %s\n' "$ui_url" >&2
printf '[harbor-connect] docker compose status:\n' >&2
docker compose -f "$COMPOSE_FILE" ps >&2 || true
printf '\n[vpn-proxy-client] recent service logs:\n' >&2
docker compose -f "$COMPOSE_FILE" logs --tail=120 vpn-proxy-client >&2 || true
printf '\n[harbor-connect] recent service logs:\n' >&2
docker compose -f "$COMPOSE_FILE" logs --tail=120 harbor-connect >&2 || true
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
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
cat <<EOF
VPN Proxy Client is running.
Harbor Connect is running.
UI:
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 }) {
const [modeAnimating, setModeAnimating] = useState(false);
const stopModeAnimationRef = useRef(false);
@@ -213,6 +219,9 @@ export function ClientOverviewPage({
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
const duration = connectionDurationParts(state?.singboxStartedAt, now);
const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено';
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean(
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
@@ -513,10 +522,10 @@ export function ClientOverviewPage({
</svg>
</button>
<div className="client-state-copy" aria-live="polite">
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
{connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено'}
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2>
<div className="client-state-detail">
{connected ? (
@@ -534,7 +543,7 @@ export function ClientOverviewPage({
>
<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="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
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
@@ -549,7 +558,9 @@ export function ClientOverviewPage({
.filter(([name, part]) => part.value || name === 'seconds')
.map(([name, part]) => (
<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>
</span>
))}

View File

@@ -921,6 +921,26 @@ p {
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 {
min-height: 20px;
display: grid;
@@ -978,12 +998,24 @@ p {
.client-duration-seconds-value {
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 {
from { opacity: 0.28; filter: blur(3px); transform: scale(0.9); }
to { opacity: 1; filter: blur(0); transform: scale(1); }
from {
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 {
@@ -1912,6 +1944,7 @@ p {
.client-copy-feedback,
.client-duration,
.client-duration-seconds-value,
.client-duration-second-digit,
.client-instructions,
.client-instructions-toggle,
.client-instructions-toggle svg,
@@ -1961,7 +1994,9 @@ p {
}
.client-state-copy h2,
.client-connection-title > span,
.client-state-detail > * {
transition: none;
animation: none;
}
}