635a130f0b
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
### Summary of Changes
1. Documentation Consolidation:
• Relocated the docs/ folder and all repository .md files (README.md, DEPLOYMENT.md, DOCKER_README.md,
DOMAINS_README.md, GETTING_STARTED.md, CI_CD_PORTAINER_GUIDE.md, ADMIN_DEPLOYMENT_SUMMARY.md,
SECURITY_PRIVATE_FILES.md, handoff.md, azure-project-plan.md, plus service-specific READMEs) into .
• Updated document links in README.md.
2. AI Agent Project Map:
• Created PROJECT_MAP.md in the project root containing complete architecture diagrams, service specs, ports, DB
schemas, environment variables, and developer workflows.
• Added PROJECT_MAP.md to .gitignore.
3. Monitoring UI & Infrastructure Integration:
• Added monitoring_ui service to docker-compose.yml (port 5175).
• Configured Gitea workflow docker-build.yml for monitoring_ui builds.
• Added CORS middleware headers in index.js.
• Added nginx.conf copy step in Dockerfile.
──────
### Recommended Commit Message
docs: consolidate project documentation into monitoring_ui and add agent project map
- Move all markdown documentation files and docs/ folder into monitoring_ui/docs/
- Update documentation hub index (monitoring_ui/docs/README.md) with relative paths
- Generate comprehensive AI agent reference map (PROJECT_MAP.md) in repository root
- Add PROJECT_MAP.md and monitoring_ui build output/env overrides to .gitignore
- Integrate monitoring_ui service into docker-compose.yml and Gitea CI workflow
- Configure CORS handling in employee_api for monitoring dashboard access
──────
### Alternative Short Commit Message
refactor(docs): move docs to monitoring_ui and create AI agent PROJECT_MAP.md
412 lines
13 KiB
Markdown
412 lines
13 KiB
Markdown
<!-- azure-cor-disclaimer -->
|
|
> **Important:** This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.
|
|
|
|
# Frontend Architecture Patterns
|
|
|
|
> Frontend has same quality bar as backend: typed, tested, structured.
|
|
|
|
> **Companion contract**: For the **visual** quality bar — how to render Section 6's layout tokens with real library primitives, how to theme the app from the plan's palette, and the four-state coverage gate — read [frontend-quality-bar.md](.github/agents/shared-references/frontend-quality-bar.md). This file covers structural patterns (types, hooks, services, error handling); the quality bar covers what each region must actually render.
|
|
|
|
---
|
|
|
|
## Core Principle
|
|
|
|
**Frontend is not second-class.** Consumes shared types package, has own test gate, follows consistent patterns for data fetching, error handling, component structure. **Layout tokens from the plan are intent, not literal `<div>`s** — see [frontend-quality-bar.md](.github/agents/shared-references/frontend-quality-bar.md).
|
|
|
|
---
|
|
|
|
## Rule: Consume Shared Types — No `any`
|
|
|
|
Shared package exists so frontend doesn't reinvent types. Every entity, request, response MUST use shared type.
|
|
|
|
```typescript
|
|
// ❌ BAD — defeats the purpose of the shared types package
|
|
const [user, setUser] = useState<any>(null);
|
|
const [photos, setPhotos] = useState<any[]>([]);
|
|
const [error, setError] = useState<any>(null);
|
|
|
|
// ✅ GOOD — imports from shared package
|
|
import type { PublicUser, Photo } from 'scrapbook-shared';
|
|
|
|
const [user, setUser] = useState<PublicUser | null>(null);
|
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
```
|
|
|
|
**Enforcement**: If shared package defines a type for an entity or API response, frontend MUST import and use it. No inline `any` or ad-hoc interfaces duplicating shared definitions.
|
|
|
|
---
|
|
|
|
## Rule: API Client Must Be Fully Typed
|
|
|
|
API client module MUST use shared request/response types for every endpoint. Client is contract boundary between frontend and backend.
|
|
|
|
```typescript
|
|
// api/client.ts
|
|
import type {
|
|
AuthResponse,
|
|
MeResponse,
|
|
LoginRequest,
|
|
RegisterRequest,
|
|
ListPhotosResponse,
|
|
PhotoResponse,
|
|
ErrorResponse,
|
|
} from 'app-shared';
|
|
|
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const token = localStorage.getItem('token');
|
|
const headers: Record<string, string> = {
|
|
...((options.headers as Record<string, string>) || {}),
|
|
};
|
|
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
if (!(options.body instanceof FormData)) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
const res = await fetch(`/api${path}`, { ...options, headers });
|
|
|
|
if (!res.ok) {
|
|
const error: ErrorResponse = await res.json();
|
|
throw new ApiError(res.status, error.error.code, error.error.message);
|
|
}
|
|
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
// Every endpoint is fully typed — no `any`
|
|
export const api = {
|
|
login: (data: LoginRequest) =>
|
|
request<AuthResponse>('POST', '/auth/login', { body: JSON.stringify(data) }),
|
|
|
|
register: (data: RegisterRequest) =>
|
|
request<AuthResponse>('POST', '/auth/register', { body: JSON.stringify(data) }),
|
|
|
|
getMe: () =>
|
|
request<MeResponse>('GET', '/auth/me'),
|
|
|
|
listPhotos: (limit = 20, offset = 0) =>
|
|
request<ListPhotosResponse>('GET', `/photos?limit=${limit}&offset=${offset}`),
|
|
|
|
uploadPhoto: (file: File) => {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
return request<PhotoResponse>('POST', '/photos', { body: form });
|
|
},
|
|
|
|
deletePhoto: (id: string) =>
|
|
request<{ success: true }>('DELETE', `/photos/${id}`),
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Rule: Error Handling in Custom Hooks
|
|
|
|
Every async op in custom hook MUST catch errors and update error state. Optimistic updates MUST roll back on failure.
|
|
|
|
```typescript
|
|
// ❌ BAD — if API call fails, UI state is inconsistent
|
|
const deletePhoto = useCallback(async (id: string) => {
|
|
setPhotos(prev => prev.filter(p => p.id !== id)); // optimistic removal
|
|
await api.deletePhoto(id); // if this throws, photo is gone from UI but not from DB
|
|
}, []);
|
|
|
|
// ✅ GOOD — rollback on failure
|
|
const deletePhoto = useCallback(async (id: string) => {
|
|
const previousPhotos = photos;
|
|
setPhotos(prev => prev.filter(p => p.id !== id)); // optimistic
|
|
try {
|
|
await api.deletePhoto(id);
|
|
} catch (err) {
|
|
setPhotos(previousPhotos); // rollback
|
|
setError(err instanceof Error ? err.message : 'Failed to delete photo');
|
|
}
|
|
}, [photos]);
|
|
```
|
|
|
|
### Silent Error Swallowing is a Bug
|
|
|
|
```typescript
|
|
// ❌ BAD — catches ALL errors including network failures, 500s
|
|
useEffect(() => {
|
|
api.getCouple().then(setCouple).catch(() => {
|
|
// "Not paired yet — that's fine"
|
|
});
|
|
}, []);
|
|
|
|
// ✅ GOOD — only ignore expected "not found" errors
|
|
useEffect(() => {
|
|
api.getCouple()
|
|
.then(setCouple)
|
|
.catch((err) => {
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
// Not paired yet — expected state
|
|
return;
|
|
}
|
|
setError('Failed to load couple info');
|
|
logger.error('Unexpected error loading couple', err);
|
|
});
|
|
}, []);
|
|
```
|
|
|
|
---
|
|
|
|
## Rule: No Destructive Actions Without Confirmation
|
|
|
|
Any action that permanently deletes or irreversibly modifies data MUST require user confirmation before executing.
|
|
|
|
```typescript
|
|
// ❌ BAD — one mis-click deletes a photo permanently
|
|
<button onClick={() => onDelete(photo.id)}>Delete</button>
|
|
|
|
// ✅ GOOD — confirmation required
|
|
<button onClick={() => {
|
|
if (window.confirm('Delete this photo? This cannot be undone.')) {
|
|
onDelete(photo.id);
|
|
}
|
|
}}>Delete</button>
|
|
```
|
|
|
|
For better UX, consider custom confirmation dialog instead of `window.confirm`.
|
|
|
|
---
|
|
|
|
## Pattern: Extract Shared Form Components
|
|
|
|
When 2+ pages share >50% structure, extract shared component. Common with auth forms.
|
|
|
|
```typescript
|
|
// ❌ BAD — LoginPage and RegisterPage are 90% identical
|
|
// LoginPage.tsx: 80 lines of form, state, error handling, submit
|
|
// RegisterPage.tsx: 85 lines of nearly identical code
|
|
|
|
// ✅ GOOD — shared AuthForm component
|
|
interface AuthFormProps {
|
|
title: string;
|
|
fields: { name: string; type: string; label: string; required?: boolean }[];
|
|
onSubmit: (data: Record<string, string>) => Promise<void>;
|
|
submitLabel: string;
|
|
altLink: { text: string; to: string };
|
|
}
|
|
|
|
function AuthForm({ title, fields, onSubmit, submitLabel, altLink }: AuthFormProps) {
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const formData = new FormData(e.target as HTMLFormElement);
|
|
const data = Object.fromEntries(formData) as Record<string, string>;
|
|
await onSubmit(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="form-container">
|
|
<h2>{title}</h2>
|
|
{error && <p className="error">{error}</p>}
|
|
<form onSubmit={handleSubmit}>
|
|
{fields.map(field => (
|
|
<input key={field.name} name={field.name} type={field.type}
|
|
placeholder={field.label} required={field.required} />
|
|
))}
|
|
<button type="submit" disabled={loading}>
|
|
{loading ? 'Please wait...' : submitLabel}
|
|
</button>
|
|
</form>
|
|
<Link to={altLink.to}>{altLink.text}</Link>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Pattern: File Upload Validation (Client-Side)
|
|
|
|
File uploads MUST validate client-side before sending to server. Provides immediate feedback, prevents wasted bandwidth.
|
|
|
|
```typescript
|
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
|
|
function validateFile(file: File): string | null {
|
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
|
return `Invalid file type: ${file.type}. Allowed: JPEG, PNG, WebP, GIF`;
|
|
}
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
return `File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: 10 MB`;
|
|
}
|
|
return null; // valid
|
|
}
|
|
|
|
// Usage in upload handler
|
|
const handleUpload = async (file: File) => {
|
|
const validationError = validateFile(file);
|
|
if (validationError) {
|
|
setError(validationError);
|
|
return;
|
|
}
|
|
// ... proceed with upload
|
|
};
|
|
```
|
|
|
|
**Server MUST also validate** — client-side validation is for UX, not security. Backend upload handler MUST independently check file size and type.
|
|
|
|
---
|
|
|
|
## Pattern: Four-State Data Pages
|
|
|
|
Every page fetching data MUST handle exactly four states:
|
|
|
|
```typescript
|
|
function PhotoGallery() {
|
|
const { photos, loading, error } = usePhotos();
|
|
|
|
// 1. Loading
|
|
if (loading) {
|
|
return <div className="loading-skeleton">Loading your memories...</div>;
|
|
}
|
|
|
|
// 2. Error (with retry)
|
|
if (error) {
|
|
return (
|
|
<div className="error-state">
|
|
<p>Something went wrong: {error}</p>
|
|
<button onClick={retry}>Try again</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 3. Empty (with helpful CTA)
|
|
if (photos.length === 0) {
|
|
return (
|
|
<div className="empty-state">
|
|
<p>No photos yet!</p>
|
|
<Link to="/upload">Upload your first memory</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 4. Data
|
|
return (
|
|
<div className="photo-grid">
|
|
{photos.map(photo => <PhotoCard key={photo.id} photo={photo} />)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Pattern: Consistent Styling Approach
|
|
|
|
Choose ONE styling approach and use it consistently. Do not mix inline styles with CSS classes.
|
|
|
|
| Approach | When to Use | How |
|
|
|----------|------------|-----|
|
|
| **CSS Modules** | Component-scoped styles, medium-large apps | `import styles from './Button.module.css'` |
|
|
| **Global CSS + BEM** | Small apps, rapid prototyping | `className="photo-card__caption"` |
|
|
| **CSS-in-JS** (styled-components, Emotion) | Dynamic themes, complex state-based styling | `const Button = styled.button\`...\`` |
|
|
| **Tailwind** | Utility-first, design-system projects | `className="flex items-center gap-2"` |
|
|
|
|
❌ Never mix inline `style={{ }}` props with CSS classes in the same codebase.
|
|
|
|
---
|
|
|
|
## Frontend Test Requirements
|
|
|
|
The frontend test gate (Step 12) requires the following minimum tests:
|
|
|
|
### Minimum Test Coverage
|
|
|
|
| Category | What to Test | Example |
|
|
|----------|-------------|---------|
|
|
| **Auth flow** | Login success, login failure, logout, token expiry redirect | Mock fetch → verify state changes |
|
|
| **Protected routes** | Unauthenticated user redirects to /login | Render route without token → expect redirect |
|
|
| **Data display** | List renders items from mock API data | Mock fetch → verify items in DOM |
|
|
| **Error states** | Error message shown when API returns error | Mock fetch 500 → verify error message |
|
|
| **Form validation** | Invalid input shows feedback | Submit empty form → verify error text |
|
|
| **Destructive actions** | Delete shows confirmation before executing | Click delete → verify confirm dialog |
|
|
|
|
### Test Setup Pattern (React + Vitest)
|
|
|
|
```typescript
|
|
// tests/setup.ts
|
|
import '@testing-library/jest-dom';
|
|
import { vi } from 'vitest';
|
|
|
|
// Mock fetch globally for all tests
|
|
global.fetch = vi.fn();
|
|
|
|
// Helper to mock successful API responses
|
|
export function mockFetchSuccess(body: unknown) {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => body,
|
|
});
|
|
}
|
|
|
|
// Helper to mock API errors
|
|
export function mockFetchError(status: number, error: { code: string; message: string }) {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: false,
|
|
status,
|
|
json: async () => ({ error: { ...error, details: null } }),
|
|
});
|
|
}
|
|
```
|
|
|
|
### Component Test Example
|
|
|
|
```typescript
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import { mockFetchSuccess, mockFetchError } from '../setup';
|
|
|
|
describe('ScrapbookPage', () => {
|
|
it('renders photo list from API', async () => {
|
|
const mockPhotos = [
|
|
{ id: '1', caption: 'Beach sunset', blobUrl: '/photo1.jpg', createdAt: '2026-01-01' },
|
|
];
|
|
mockFetchSuccess({ photos: mockPhotos, total: 1 });
|
|
|
|
render(<MemoryRouter><ScrapbookPage /></MemoryRouter>);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Beach sunset')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows error when API fails', async () => {
|
|
mockFetchError(500, { code: 'INTERNAL_ERROR', message: 'Server error' });
|
|
|
|
render(<MemoryRouter><ScrapbookPage /></MemoryRouter>);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows empty state when no photos', async () => {
|
|
mockFetchSuccess({ photos: [], total: 0 });
|
|
|
|
render(<MemoryRouter><ScrapbookPage /></MemoryRouter>);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/no photos yet/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
```
|