Compare commits

..
Author SHA1 Message Date
Claude 844f6fa09f Add chat history sidebar with restore across reloads
Chat messages previously lived only in React state and vanished on
navigation or refresh. Persist each conversation as a session in
localStorage and add a sidebar to switch between, restore, or delete
past chats, matching the pattern of the account page's client-side
persistence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RrMZ8Y9BhuMkKNcw2bakZ
2026-08-18 21:11:04 +00:00
6 changed files with 265 additions and 84 deletions

No files matched your search

-69
View File
@@ -1,69 +0,0 @@
name: Docker
# Builds the frontend for the HOMELAB instance and pushes it to this Gitea's
# registry.
#
# Read the build args before changing anything: Vite inlines import.meta.env at
# BUILD time, so VITE_API_URL is baked into the bundle and an image is tied to
# one API. The GitHub build bakes https://api.t4t-gateway.com -- running that
# image here would serve a UI at t4tgw.ffaerber.duckdns.org that talks to the
# VPS, so a test signup would create a real account on production. This build
# bakes the homelab API instead, which is the whole reason the two instances can
# run side by side and mean anything.
#
# VITE_GOOGLE_CLIENT_ID comes from a repo secret; unset, Google sign-in is the
# only thing that breaks, and the rest of the instance still exercises.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
env:
HOMELAB_API_URL: https://t4tgw-api.ffaerber.duckdns.org
jobs:
build:
runs-on: ci-runner
steps:
- uses: actions/checkout@v4
- name: Build
run: |
set -eu
SHA=$(echo "${{ github.sha }}" | cut -c1-7)
echo "IMAGE=git.ffaerber.duckdns.org/token-for-token/hosted-gateway-frontend" >> "$GITHUB_ENV"
echo "SHA=$SHA" >> "$GITHUB_ENV"
docker build \
--build-arg VITE_API_URL="$HOMELAB_API_URL" \
--build-arg VITE_GOOGLE_CLIENT_ID="${{ secrets.VITE_GOOGLE_CLIENT_ID }}" \
--build-arg VITE_COMMIT_SHA=${{ github.sha }} \
-t "git.ffaerber.duckdns.org/token-for-token/hosted-gateway-frontend:$SHA" .
- name: Push
if: github.event_name != 'pull_request'
run: |
set -eu
# Not the automatic Actions token: this Gitea's package registry rejects
# it (docker login returns a bare "unauthorized"), with or without a
# permissions: packages: write block. REGISTRY_TOKEN is an org secret
# holding a personal access token scoped to write:package only.
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "REGISTRY_TOKEN is not set — create a token with the write:package scope" >&2
echo "and add it as an org secret on token-for-token. The image built fine;" >&2
echo "only the push needs it." >&2
exit 1
fi
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.ffaerber.duckdns.org -u "${{ vars.REGISTRY_USER || 'ffaerber' }}" --password-stdin
docker tag "$IMAGE:$SHA" "$IMAGE:latest"
docker push "$IMAGE:$SHA"
docker push "$IMAGE:latest"
docker logout git.ffaerber.duckdns.org
{
echo "### Pushed"
echo
echo '```'
echo "$IMAGE:$SHA"
echo '```'
echo
echo "API baked in: $HOMELAB_API_URL"
} >> "$GITHUB_STEP_SUMMARY"
+54
View File
@@ -0,0 +1,54 @@
name: Docker Image CI
on:
push:
branches: ['main', 'develop']
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/hosted-gateway-frontend:${{ github.ref_name }}
ghcr.io/${{ github.repository_owner }}/hosted-gateway-frontend:${{ github.sha }}
build-args: |
VITE_API_URL=https://api.t4t-gateway.com
VITE_GOOGLE_CLIENT_ID=${{ secrets.VITE_GOOGLE_CLIENT_ID }}
VITE_COMMIT_SHA=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: docker stack deploy
uses: appleboy/ssh-action@v1.0.3
with:
username: root
host: ${{ secrets.SSH_KNOWN_HOSTS }}
key: ${{ secrets.SSH_PRIV_KEY }}
script: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker pull ghcr.io/token-for-token/hosted-gateway-frontend:main
docker stack deploy -c ~/devops/hostinger/prod-stack.yaml prod --with-registry-auth --resolve-image always
docker image prune -f --filter "until=24h"
-15
View File
@@ -19,21 +19,6 @@ WORKDIR /app
RUN npm ci --omit=dev
FROM public.ecr.aws/docker/library/node:20-alpine AS build-env
# These have to be declared HERE, not only in development-dependencies-env:
# build args do not cross stages, and this is the stage that runs `npm run
# build`. Without them Vite inlines the fallbacks from app/routes/*.tsx --
# https://api.t4t-gateway.com -- so an image built for anywhere else silently
# drives production.
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
ARG VITE_COMMIT_SHA
ENV VITE_COMMIT_SHA=$VITE_COMMIT_SHA
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
+1
View File
@@ -3,6 +3,7 @@ import { NavLink } from 'react-router';
const ITEMS = [
{ to: '/chat', label: 'Chat' },
{ to: '/account', label: 'Account' },
{ to: '/bee', label: 'Bee' },
];
export function Nav() {
+1
View File
@@ -7,4 +7,5 @@ export default [
route('login', 'routes/login.tsx'),
route('account', 'routes/account.tsx'),
route('chat', 'routes/chat.tsx'),
route('bee', 'routes/bee.tsx'),
] satisfies RouteConfig;
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { Nav } from '~/components/nav';
const API_URL = import.meta.env.VITE_API_URL ?? 'https://api.t4t-gateway.com';
type Health = { status: string; version: string; apiVersion: string };
type Addresses = {
overlay: string;
ethereum: string;
publicKey: string;
pssPublicKey: string;
underlay: string[];
};
type Topology = {
connected: number;
population: number;
depth: number;
reachability: string;
networkAvailability: string;
};
type Wallet = {
bzz: string;
xdai: string;
walletAddress: string;
chainID: number;
};
type Stamp = {
batchID: string;
label: string;
depth: number;
amount: string;
usable: boolean;
usageText: string;
durationDays: number;
};
type ChainState = { block: number; chainTip: number; currentPrice: number };
type Stats = {
health: Health | null;
addresses: Addresses | null;
topology: Topology | null;
wallet: Wallet | null;
stamps: Stamp[] | null;
chain: ChainState | null;
};
export function meta() {
return [{ title: 'Bee · t4t Gateway' }];
}
async function fetchStats(): Promise<Stats> {
const token = localStorage.getItem('jwt');
if (!token) throw new Error('Not authenticated');
const res = await fetch(`${API_URL}/bee/stats`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
localStorage.removeItem('jwt');
window.location.href = '/login';
throw new Error('Unauthorized');
}
const body = await res.json();
if (!res.ok) throw new Error(body?.error ?? `Request failed (${res.status})`);
return body as Stats;
}
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className='rounded-lg border border-neutral-200 bg-white p-5 shadow-sm'>
<h2 className='text-xs font-semibold uppercase tracking-wide text-neutral-500'>{title}</h2>
<div className='mt-3 text-sm'>{children}</div>
</section>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className='flex justify-between gap-4 py-1'>
<span className='text-neutral-500'>{label}</span>
<span className='break-all text-right font-mono text-neutral-900'>{value}</span>
</div>
);
}
export default function BeePage() {
const [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (typeof window === 'undefined') return;
if (!localStorage.getItem('jwt')) {
window.location.href = '/login';
return;
}
fetchStats()
.then((s) => setStats(s))
.catch((err) => setError(err instanceof Error ? err.message : 'Unknown error'))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<>
<Nav />
<main className='flex min-h-[60vh] items-center justify-center'>
<p className='text-sm text-neutral-600'>Loading Bee stats…</p>
</main>
</>
);
}
return (
<>
<Nav />
<main className='mx-auto max-w-3xl px-6 py-10'>
<h1 className='text-2xl font-semibold'>Bee node</h1>
<p className='mt-1 text-sm text-neutral-600'>Read-only status of the operator's Bee node.</p>
{error && (
<p className='mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700'>{error}</p>
)}
<div className='mt-6 grid gap-4'>
<Card title='Health'>
{stats?.health ? (
<>
<Row label='Status' value={stats.health.status} />
<Row label='Version' value={stats.health.version} />
<Row label='API version' value={stats.health.apiVersion} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Addresses'>
{stats?.addresses ? (
<>
<Row label='Overlay' value={stats.addresses.overlay} />
<Row label='Ethereum' value={stats.addresses.ethereum} />
<Row label='Underlays' value={stats.addresses.underlay.length} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Topology'>
{stats?.topology ? (
<>
<Row label='Connected peers' value={stats.topology.connected} />
<Row label='Known peers' value={stats.topology.population} />
<Row label='Depth' value={stats.topology.depth} />
<Row label='Reachability' value={stats.topology.reachability} />
<Row label='Network availability' value={stats.topology.networkAvailability} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Wallet'>
{stats?.wallet ? (
<>
<Row label='Address' value={stats.wallet.walletAddress} />
<Row label='xBZZ' value={stats.wallet.bzz} />
<Row label='xDAI' value={stats.wallet.xdai} />
<Row label='Chain ID' value={stats.wallet.chainID} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Chain state'>
{stats?.chain ? (
<>
<Row label='Block' value={stats.chain.block} />
<Row label='Chain tip' value={stats.chain.chainTip} />
<Row label='Current price (PLUR/chunk)' value={stats.chain.currentPrice} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Postage batches'>
{stats?.stamps && stats.stamps.length > 0 ? (
<ul className='divide-y divide-neutral-200'>
{stats.stamps.map((s) => (
<li key={s.batchID} className='py-2'>
<p className='font-mono text-xs text-neutral-900'>{s.batchID}</p>
<p className='mt-1 text-xs text-neutral-500'>
{s.label || '(unlabeled)'} · depth {s.depth} · used {s.usageText} ·{' '}
{s.usable ? 'usable' : 'not usable'} · {s.durationDays.toFixed(1)}d left
</p>
</li>
))}
</ul>
) : (
<p className='text-neutral-500'>no batches</p>
)}
</Card>
</div>
</main>
</>
);
}