Replace startup mocks with loading skeletons
This commit is contained in:
@@ -47,3 +47,4 @@ Updated: 2026-04-02
|
||||
30. Added a websocket heartbeat so time-based status transitions such as `live -> idle/warn` are recalculated predictably even when no new proxy events arrive.
|
||||
31. Moved proxy-copy into the Users actions column, added a last-seen/online column from parsed 3proxy logs, and introduced bounded websocket/API reconnect attempts with a visible connection banner and forced logout after full recovery failure.
|
||||
32. Restored proxy-link copying for plain-`http` deployments by falling back from the Clipboard API to `execCommand('copy')`, and added regression coverage for both clipboard paths.
|
||||
33. Replaced stale startup mock values with explicit skeleton loading states so the shell no longer flashes fallback dashboard/users/settings data before the first live snapshot arrives.
|
||||
|
||||
@@ -23,10 +23,10 @@ Updated: 2026-04-02
|
||||
## Frontend
|
||||
|
||||
- `src/main.tsx`: application bootstrap
|
||||
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, log-derived last-seen user labels, icon-based user actions, HTTP-safe proxy-link copying fallback, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations
|
||||
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, skeleton-first startup hydration, log-derived last-seen user labels, icon-based user actions, HTTP-safe proxy-link copying fallback, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations
|
||||
- `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, dirty-draft protection against incoming live sync, unified service type editing, remove confirmation, and generated config preview
|
||||
- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, clipboard fallback coverage, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests
|
||||
- `src/app.css`: full panel styling including fixed-width icon action buttons, busy-state treatment, and connection banner styling
|
||||
- `src/App.test.tsx`: login-gate, startup skeleton, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, clipboard fallback coverage, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests
|
||||
- `src/app.css`: full panel styling including fixed-width icon action buttons, busy-state treatment, startup skeleton shimmer states, and connection banner styling
|
||||
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
|
||||
- `src/lib/3proxy.ts`: formatting and status helpers
|
||||
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
|
||||
|
||||
@@ -9,6 +9,12 @@ async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.type(screen.getByLabelText(/login/i), 'admin');
|
||||
await user.type(screen.getByLabelText(/password/i), 'proxy-ui-demo');
|
||||
await user.click(screen.getByRole('button', { name: /open panel/i }));
|
||||
await waitFor(() => expect(MockWebSocket.instances.length).toBeGreaterThan(0));
|
||||
MockWebSocket.instances.at(-1)?.emitMessage({
|
||||
type: 'snapshot.init',
|
||||
snapshot: fallbackDashboardSnapshot,
|
||||
});
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /settings/i })).not.toBeDisabled());
|
||||
}
|
||||
|
||||
function mockClipboardWriteText(writeText: ReturnType<typeof vi.fn>) {
|
||||
@@ -64,6 +70,19 @@ describe('App login gate', () => {
|
||||
expect(screen.queryByRole('button', { name: /open panel/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading skeletons instead of stale mock values before the first snapshot arrives', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
await user.type(screen.getByLabelText(/login/i), 'admin');
|
||||
await user.type(screen.getByLabelText(/password/i), 'proxy-ui-demo');
|
||||
await user.click(screen.getByRole('button', { name: /open panel/i }));
|
||||
|
||||
expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/edge\.example\.net/i)).not.toBeInTheDocument();
|
||||
expect(document.querySelectorAll('.skeleton-line').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('stores panel language in localStorage and restores it after a remount', async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstRender = render(<App />);
|
||||
|
||||
269
src/App.tsx
269
src/App.tsx
@@ -831,6 +831,259 @@ function TrashIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonLine({ className = '' }: { className?: string }) {
|
||||
return <span className={`skeleton-line ${className}`.trim()} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function ShellLoadingState({
|
||||
activeTab,
|
||||
text,
|
||||
}: {
|
||||
activeTab: TabId;
|
||||
text: ReturnType<typeof getPanelText>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<header className="shell-header" aria-busy="true">
|
||||
<div className="shell-title">
|
||||
<h1>3proxy UI</h1>
|
||||
<SkeletonLine className="skeleton-host" />
|
||||
</div>
|
||||
<div className="header-meta">
|
||||
{[text.common.status, text.common.version, text.common.users].map((label) => (
|
||||
<div key={label}>
|
||||
<span>{label}</span>
|
||||
<SkeletonLine className="skeleton-meta-value" />
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="button-secondary" disabled>
|
||||
{text.common.signOut}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="tabbar" aria-label="Primary">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
|
||||
disabled
|
||||
>
|
||||
{text.tabs[tab.textKey]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{activeTab === 'dashboard' ? <DashboardSkeleton text={text} /> : null}
|
||||
{activeTab === 'users' ? <UsersSkeleton text={text} /> : null}
|
||||
{activeTab === 'system' ? <SystemSkeleton text={text} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSkeleton({ text }: { text: ReturnType<typeof getPanelText> }) {
|
||||
return (
|
||||
<section className="page-grid" aria-busy="true">
|
||||
<article className="panel-card">
|
||||
<div className="card-header">
|
||||
<h2>{text.dashboard.service}</h2>
|
||||
<SkeletonLine className="skeleton-pill" />
|
||||
</div>
|
||||
<dl className="kv-list">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index}>
|
||||
<dt><SkeletonLine className="skeleton-label" /></dt>
|
||||
<dd><SkeletonLine className="skeleton-value" /></dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="actions-row">
|
||||
<button type="button" disabled>{text.dashboard.start}</button>
|
||||
<button type="button" className="button-secondary" disabled>{text.dashboard.stop}</button>
|
||||
<button type="button" className="button-secondary" disabled>{text.dashboard.restart}</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="card-header">
|
||||
<h2>{text.dashboard.traffic}</h2>
|
||||
</div>
|
||||
<div className="stats-strip">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index}>
|
||||
<SkeletonLine className="skeleton-label" />
|
||||
<SkeletonLine className="skeleton-value" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="card-header">
|
||||
<h2>{text.dashboard.dailyUsage}</h2>
|
||||
</div>
|
||||
<div className="usage-list">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="usage-row">
|
||||
<SkeletonLine className="skeleton-day" />
|
||||
<div className="usage-bar skeleton-bar-track">
|
||||
<div style={{ width: `${55 + index * 8}%` }} />
|
||||
</div>
|
||||
<SkeletonLine className="skeleton-bytes" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="card-header">
|
||||
<h2>{text.dashboard.attention}</h2>
|
||||
</div>
|
||||
<div className="event-list">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="event-row skeleton-event-row">
|
||||
<span className="event-marker neutral" />
|
||||
<div>
|
||||
<SkeletonLine className="skeleton-value" />
|
||||
<SkeletonLine className="skeleton-copy" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersSkeleton({ text }: { text: ReturnType<typeof getPanelText> }) {
|
||||
return (
|
||||
<section className="page-grid single-column" aria-busy="true">
|
||||
<article className="panel-card">
|
||||
<div className="table-toolbar">
|
||||
<div className="toolbar-title">
|
||||
<h2>{text.users.title}</h2>
|
||||
<SkeletonLine className="skeleton-copy short" />
|
||||
</div>
|
||||
<div className="toolbar-actions">
|
||||
<div className="summary-pills">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<span key={index}><SkeletonLine className="skeleton-pill-text" /></span>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" disabled>{text.users.newUser}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{text.users.user}</th>
|
||||
<th>{text.users.endpoint}</th>
|
||||
<th>{text.common.status}</th>
|
||||
<th>{text.users.used}</th>
|
||||
<th>{text.users.remaining}</th>
|
||||
<th>{text.users.online}</th>
|
||||
<th>{text.users.share}</th>
|
||||
<th>{text.users.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<tr key={index}>
|
||||
<td><SkeletonLine className="skeleton-copy" /></td>
|
||||
<td><SkeletonLine className="skeleton-copy" /></td>
|
||||
<td><SkeletonLine className="skeleton-pill" /></td>
|
||||
<td><SkeletonLine className="skeleton-inline" /></td>
|
||||
<td><SkeletonLine className="skeleton-inline" /></td>
|
||||
<td><SkeletonLine className="skeleton-inline" /></td>
|
||||
<td><SkeletonLine className="skeleton-inline" /></td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
{Array.from({ length: 4 }).map((__, actionIndex) => (
|
||||
<span key={actionIndex} className="icon-button secondary skeleton-icon-button" aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemSkeleton({ text }: { text: ReturnType<typeof getPanelText> }) {
|
||||
return (
|
||||
<section className="page-grid single-column system-grid" aria-busy="true">
|
||||
<article className="panel-card">
|
||||
<div className="card-header">
|
||||
<h2>{text.settings.panelTitle}</h2>
|
||||
</div>
|
||||
<div className="panel-settings-grid">
|
||||
<div className="field-group panel-settings-wide">
|
||||
<span>{text.settings.proxyHost}</span>
|
||||
<SkeletonLine className="skeleton-input" />
|
||||
</div>
|
||||
<div className="field-group compact-field">
|
||||
<span>{text.common.language}</span>
|
||||
<SkeletonLine className="skeleton-input" />
|
||||
</div>
|
||||
<div className="field-group compact-field">
|
||||
<span>{text.common.theme}</span>
|
||||
<SkeletonLine className="skeleton-input" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="card-header">
|
||||
<h2>{text.settings.title}</h2>
|
||||
<button type="button" className="button-secondary" disabled>{text.common.addService}</button>
|
||||
</div>
|
||||
<div className="service-editor-list">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<section key={index} className="service-editor-row">
|
||||
<div className="service-editor-header">
|
||||
<div>
|
||||
<SkeletonLine className="skeleton-value" />
|
||||
<SkeletonLine className="skeleton-inline" />
|
||||
</div>
|
||||
<button type="button" className="button-secondary button-small" disabled>{text.common.remove}</button>
|
||||
</div>
|
||||
<div className="service-editor-grid">
|
||||
{Array.from({ length: 4 }).map((__, fieldIndex) => (
|
||||
<div key={fieldIndex} className={`field-group${fieldIndex === 3 ? ' field-span-2' : ''}`}>
|
||||
<SkeletonLine className="skeleton-label" />
|
||||
<SkeletonLine className="skeleton-input" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div className="system-actions">
|
||||
<button type="button" className="button-secondary" disabled>{text.common.reset}</button>
|
||||
<button type="button" disabled>{text.common.saveSettings}</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel-card wide-card">
|
||||
<div className="card-header">
|
||||
<h2>{text.settings.generatedConfig}</h2>
|
||||
</div>
|
||||
<div className="config-skeleton">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<SkeletonLine key={index} className={`skeleton-config-line${index % 3 === 0 ? ' long' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [preferences, setPreferences] = useState<PanelPreferences>(() => {
|
||||
const loaded = loadPanelPreferences();
|
||||
@@ -841,6 +1094,7 @@ export default function App() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>(() => readTabFromHash(window.location.hash));
|
||||
const [connectionNotice, setConnectionNotice] = useState<string | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
|
||||
const [hasSnapshotHydrated, setHasSnapshotHydrated] = useState(false);
|
||||
const text = getPanelText(preferences.language);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -874,6 +1128,7 @@ export default function App() {
|
||||
const resetSession = () => {
|
||||
clearStoredSession();
|
||||
setConnectionNotice(null);
|
||||
setHasSnapshotHydrated(false);
|
||||
setSession(null);
|
||||
};
|
||||
|
||||
@@ -918,6 +1173,8 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasSnapshotHydrated(false);
|
||||
|
||||
let cancelled = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
let socket: WebSocket | null = null;
|
||||
@@ -941,6 +1198,7 @@ export default function App() {
|
||||
|
||||
if (!cancelled && payload) {
|
||||
setSnapshot(payload);
|
||||
setHasSnapshotHydrated(true);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1043,12 +1301,14 @@ export default function App() {
|
||||
|
||||
if (message.type === 'snapshot.init') {
|
||||
setSnapshot(message.snapshot);
|
||||
setHasSnapshotHydrated(true);
|
||||
setConnectionNotice(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'snapshot.patch') {
|
||||
setSnapshot((current) => applySnapshotPatch(current, message.patch));
|
||||
setHasSnapshotHydrated(true);
|
||||
setConnectionNotice(null);
|
||||
return;
|
||||
}
|
||||
@@ -1087,6 +1347,15 @@ export default function App() {
|
||||
return <LoginGate onUnlock={handleUnlock} preferences={preferences} />;
|
||||
}
|
||||
|
||||
if (!hasSnapshotHydrated) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<ShellLoadingState activeTab={activeTab} text={text} />
|
||||
{connectionNotice ? <div className="connection-banner">{connectionNotice}</div> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const mutateSnapshot = async (
|
||||
request: () => Promise<Response>,
|
||||
fallback: (current: DashboardSnapshot) => DashboardSnapshot,
|
||||
|
||||
130
src/app.css
130
src/app.css
@@ -191,6 +191,132 @@ button,
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skeleton-line,
|
||||
.skeleton-icon-button {
|
||||
display: inline-flex;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--surface-muted) 82%, transparent) 0%,
|
||||
color-mix(in srgb, var(--border) 85%, var(--surface-muted)) 50%,
|
||||
color-mix(in srgb, var(--surface-muted) 82%, transparent) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-host {
|
||||
width: 168px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.skeleton-meta-value {
|
||||
width: 72px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.skeleton-label {
|
||||
width: 84px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.skeleton-value {
|
||||
width: 140px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.skeleton-copy {
|
||||
width: 180px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.skeleton-copy.short {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.skeleton-inline {
|
||||
width: 88px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.skeleton-day {
|
||||
width: 32px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.skeleton-bytes {
|
||||
width: 72px;
|
||||
height: 16px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.skeleton-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.skeleton-pill {
|
||||
width: 72px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.skeleton-pill-text {
|
||||
width: 64px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.skeleton-bar-track {
|
||||
background: color-mix(in srgb, var(--surface-muted) 75%, var(--border));
|
||||
}
|
||||
|
||||
.skeleton-bar-track div {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--accent) 28%, var(--surface-muted)) 0%,
|
||||
color-mix(in srgb, var(--accent) 42%, var(--surface-muted)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.skeleton-event-row {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.skeleton-config-line {
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.skeleton-config-line.long {
|
||||
width: 82%;
|
||||
}
|
||||
|
||||
.config-skeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--surface-muted) 80%, var(--surface));
|
||||
}
|
||||
|
||||
.skeleton-icon-button {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.button-small {
|
||||
min-width: 72px;
|
||||
height: 32px;
|
||||
@@ -453,6 +579,10 @@ button,
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.event-marker.neutral {
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
Reference in New Issue
Block a user