Replace startup mocks with loading skeletons

This commit is contained in:
2026-04-02 04:14:56 +03:00
parent eb64f70269
commit db331e373e
5 changed files with 422 additions and 3 deletions

View File

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

View File

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

View File

@@ -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 />);

View File

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

View File

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