diff --git a/.github/workflows/validate-bicep-params.yml b/.github/workflows/validate-bicep-params.yml index bcb03f097..656299974 100644 --- a/.github/workflows/validate-bicep-params.yml +++ b/.github/workflows/validate-bicep-params.yml @@ -34,9 +34,16 @@ jobs: - name: Validate infra/ parameters id: validate_infra continue-on-error: true + env: + ACCELERATOR_NAME: ${{ env.accelerator_name }} run: | set +e - python infra/scripts/validate_bicep_params.py --dir infra --strict --no-color --json-output infra_results.json 2>&1 | tee infra_output.txt + RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + python infra/scripts/validate_bicep_params.py --dir infra --strict --no-color \ + --json-output infra_results.json \ + --html-output email_body.html \ + --accelerator-name "${ACCELERATOR_NAME}" \ + --run-url "${RUN_URL}" 2>&1 | tee infra_output.txt EXIT_CODE=${PIPESTATUS[0]} set -e echo "## Infra Param Validation" >> "$GITHUB_STEP_SUMMARY" @@ -61,24 +68,25 @@ jobs: name: bicep-validation-results path: | infra_results.json + email_body.html retention-days: 30 - name: Send schedule notification on failure if: github.event_name == 'schedule' && steps.result.outputs.status == 'failure' env: LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_RUN_ID: ${{ github.run_id }} ACCELERATOR_NAME: ${{ env.accelerator_name }} run: | - RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - INFRA_OUTPUT=$(sed 's/&/\&/g; s//\>/g' infra_output.txt) + if [ -f email_body.html ]; then + EMAIL_BODY=$(cat email_body.html) + else + EMAIL_BODY="

Bicep parameter validation failed but no HTML report was generated. Check workflow logs for details.

" + fi jq -n \ --arg name "${ACCELERATOR_NAME}" \ - --arg infra "$INFRA_OUTPUT" \ - --arg url "$RUN_URL" \ - '{subject: ("Bicep Parameter Validation Report - " + $name + " - Issues Detected"), body: ("

Dear Team,

The scheduled Bicep Parameter Validation for " + $name + " has detected parameter mapping errors.

infra/ Results:

" + $infra + "

Run URL: " + $url + "

Please fix the parameter mapping issues at your earliest convenience.

Best regards,
Your Automation Team

")}' \ + --arg body "$EMAIL_BODY" \ + '{subject: ("Bicep Parameter Validation Report - " + $name + " - Issues Detected"), body: $body}' \ | curl -X POST "${LOGICAPP_URL}" \ -H "Content-Type: application/json" \ -d @- || echo "Failed to send notification" @@ -87,18 +95,18 @@ jobs: if: github.event_name == 'schedule' && steps.result.outputs.status == 'success' env: LOGICAPP_URL: ${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_RUN_ID: ${{ github.run_id }} ACCELERATOR_NAME: ${{ env.accelerator_name }} run: | - RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - INFRA_OUTPUT=$(sed 's/&/\&/g; s//\>/g' infra_output.txt) + if [ -f email_body.html ]; then + EMAIL_BODY=$(cat email_body.html) + else + EMAIL_BODY="

Bicep parameter validation passed but no HTML report was generated (no parameter file pairs found).

" + fi jq -n \ --arg name "${ACCELERATOR_NAME}" \ - --arg infra "$INFRA_OUTPUT" \ - --arg url "$RUN_URL" \ - '{subject: ("Bicep Parameter Validation Report - " + $name + " - Passed"), body: ("

Dear Team,

The scheduled Bicep Parameter Validation for " + $name + " has completed successfully. All parameter mappings are valid.

infra/ Results:

" + $infra + "

Run URL: " + $url + "

Best regards,
Your Automation Team

")}' \ + --arg body "$EMAIL_BODY" \ + '{subject: ("Bicep Parameter Validation Report - " + $name + " - Passed"), body: $body}' \ | curl -X POST "${LOGICAPP_URL}" \ -H "Content-Type: application/json" \ -d @- || echo "Failed to send notification" diff --git a/docs/QuotaCheck.md b/docs/QuotaCheck.md index 3f73376a3..f67f4f5ad 100644 --- a/docs/QuotaCheck.md +++ b/docs/QuotaCheck.md @@ -72,7 +72,7 @@ The final table lists regions with available quota. You can select any of these **To check quota for the deployment** ```sh - curl -L -o quota_check_params.sh "https://raw.githubusercontent.com/microsoft/content-generation-solution-accelerator/main/content-gen/infra/scripts/quota_check_params.sh" + curl -L -o quota_check_params.sh "https://raw.githubusercontent.com/microsoft/content-generation-solution-accelerator/main/infra/scripts/quota_check_params.sh" chmod +x quota_check_params.sh ./quota_check_params.sh ``` diff --git a/docs/create_new_app_registration.md b/docs/create_new_app_registration.md index 7dcf2c402..732f0aa75 100644 --- a/docs/create_new_app_registration.md +++ b/docs/create_new_app_registration.md @@ -20,7 +20,7 @@ ![Redirect URL](images/AddRedirectURL.png) -6. Click on `+ Add a platform`. +6. Click on `+ Add Redirect URI`. ![+ Add platform](images/AddPlatform.png) diff --git a/docs/images/AddDetails.png b/docs/images/AddDetails.png index f36b596f2..f5946c6db 100644 Binary files a/docs/images/AddDetails.png and b/docs/images/AddDetails.png differ diff --git a/docs/images/AddPlatform.png b/docs/images/AddPlatform.png index 6c74919b4..2424f2a8f 100644 Binary files a/docs/images/AddPlatform.png and b/docs/images/AddPlatform.png differ diff --git a/docs/images/Web.png b/docs/images/Web.png index 35f846453..d997cbd3a 100644 Binary files a/docs/images/Web.png and b/docs/images/Web.png differ diff --git a/infra/scripts/validate_bicep_params.py b/infra/scripts/validate_bicep_params.py index 85df7d149..4fd76a293 100644 --- a/infra/scripts/validate_bicep_params.py +++ b/infra/scripts/validate_bicep_params.py @@ -340,6 +340,246 @@ def print_report(results: list[ValidationResult], *, use_color: bool = True) -> print(f"{c['ERROR']}Parameter mapping issues detected!{c['RESET']}") +# --------------------------------------------------------------------------- +# HTML email report +# --------------------------------------------------------------------------- + +def _html_escape(text: str) -> str: + """Escape HTML special characters.""" + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def generate_html_report( + results: list[ValidationResult], + *, + accelerator_name: str = "", + run_url: str = "", + scan_dir: str = "", +) -> str: + """Build a structured HTML email body from validation results.""" + total_errors = sum( + 1 for r in results for i in r.issues if i.severity == "ERROR" + ) + total_warnings = sum( + 1 for r in results for i in r.issues if i.severity == "WARNING" + ) + has_errors = total_errors > 0 + overall_status = "Issues Detected" if has_errors else "Passed" + status_color = "#D32F2F" if has_errors else "#2E7D32" + status_bg = "#FFEBEE" if has_errors else "#E8F5E9" + status_icon = "❌" if has_errors else "✅" + + parts: list[str] = [] + + # --- Document wrapper (Outlook-compatible, no gradient/border-radius/box-shadow) --- + parts.append( + '' + '' + '' + '
' + '' + ) + + # --- Header banner (solid color, Outlook-safe) --- + parts.append( + f'' + ) + + # --- Summary card --- + parts.append( + f'") + + # --- Per-pair detail sections --- + parts.append('") + + # --- Footer with run URL --- + footer_parts: list[str] = [] + if run_url: + footer_parts.append( + f'View Workflow Run' + ) + if has_errors: + footer_parts.append( + '

' + 'Please fix the parameter mapping issues at your earliest convenience.

' + ) + footer_parts.append( + '

' + 'Best regards,
Your Automation Team

' + ) + parts.append( + f'' + ) + + # --- Close wrapper --- + parts.append("
' + f'

' + f'Bicep Parameter Validation Report

' + f'

' + f'{_html_escape(accelerator_name) if accelerator_name else "Accelerator"}' + f' — Automated Check

' + f'
' + f'' + f'' + f'
' + f'' + f'{status_icon} Overall Status: {overall_status}' + f'
' + f'' + ) + # Accelerator name pill + if accelerator_name: + parts.append( + f'' + ) + # Scan directory pill + if scan_dir: + parts.append( + f'' + ) + # Error count pill + err_pill_color = "#D32F2F" if total_errors > 0 else "#2E7D32" + parts.append( + f'' + ) + # Warning count pill + warn_pill_color = "#F57C00" if total_warnings > 0 else "#2E7D32" + parts.append( + f'' + ) + parts.append("
' + f'Accelerator
' + f'{_html_escape(accelerator_name)}' + f'
' + f'Scan Directory
' + f'{_html_escape(scan_dir)}/' + f'
' + f'Errors
' + f'' + f'{total_errors}
' + f'Warnings
' + f'' + f'{total_warnings}
') + for r in results: + errors = [i for i in r.issues if i.severity == "ERROR"] + warnings = [i for i in r.issues if i.severity == "WARNING"] + + if not r.issues: + badge = ( + 'PASS' + ) + elif errors: + badge = ( + 'FAIL' + ) + else: + badge = ( + 'WARN' + ) + + parts.append( + f'' + f'' + ) + + if r.issues: + # --- Errors section --- + if errors: + parts.append( + '' + '") + + # --- Warnings section --- + if warnings: + parts.append( + '' + '") + else: + parts.append( + '' + ) + + parts.append("
' + f'{badge} ' + f'' + f'{_html_escape(r.pair)}' + f'' + f'{len(errors)} error(s), {len(warnings)} warning(s)' + f'
' + '' + '● Errors
' + '' + '' + '' + '' + ) + for idx, issue in enumerate(errors): + bg = "#ffffff" if idx % 2 == 0 else "#fff5f5" + parts.append( + f'' + f'' + f'' + f'' + ) + parts.append("
ParameterDetails
' + f'{_html_escape(issue.param_name)}{_html_escape(issue.message)}
' + '' + '● Warnings
' + '' + '' + '' + '' + ) + for idx, issue in enumerate(warnings): + bg = "#ffffff" if idx % 2 == 0 else "#fffaf0" + parts.append( + f'' + f'' + f'' + f'' + ) + parts.append("
ParameterDetails
' + f'{_html_escape(issue.param_name)}{_html_escape(issue.message)}
All parameters validated successfully.' + '
") + + parts.append("
' + f'{"".join(footer_parts)}
") + return "".join(parts) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -378,6 +618,23 @@ def main() -> int: type=Path, help="Write results as JSON to the given file path.", ) + parser.add_argument( + "--html-output", + type=Path, + help="Write a structured HTML email report to the given file path.", + ) + parser.add_argument( + "--accelerator-name", + type=str, + default="", + help="Accelerator display name for the HTML report header.", + ) + parser.add_argument( + "--run-url", + type=str, + default="", + help="Workflow run URL to include in the HTML report footer.", + ) args = parser.parse_args() results: list[ValidationResult] = [] @@ -388,6 +645,23 @@ def main() -> int: pairs = discover_pairs(args.dir) if not pairs: print(f"No (bicep, parameters.json) pairs found under {args.dir}") + # Generate empty reports so downstream workflow steps (e.g. cat + # email_body.html) do not fail when no pairs are discovered. + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text( + json.dumps([], indent=2), encoding="utf-8" + ) + if args.html_output: + scan_dir = str(args.dir) if args.dir else "" + html = generate_html_report( + [], + accelerator_name=args.accelerator_name, + run_url=args.run_url, + scan_dir=scan_dir, + ) + args.html_output.parent.mkdir(parents=True, exist_ok=True) + args.html_output.write_text(html, encoding="utf-8") return 0 for bicep_path, params_path in pairs: results.append(validate_pair(bicep_path, params_path)) @@ -414,6 +688,19 @@ def main() -> int: ) print(f"\nJSON report written to {args.json_output}") + # Optional HTML email report + if args.html_output: + scan_dir = str(args.dir) if args.dir else "" + html = generate_html_report( + results, + accelerator_name=args.accelerator_name, + run_url=args.run_url, + scan_dir=scan_dir, + ) + args.html_output.parent.mkdir(parents=True, exist_ok=True) + args.html_output.write_text(html, encoding="utf-8") + print(f"HTML report written to {args.html_output}") + has_errors = any(r.has_errors for r in results) return 1 if args.strict and has_errors else 0 diff --git a/scripts/post_deploy.py b/scripts/post_deploy.py index 202d7ebeb..72e8d0a67 100644 --- a/scripts/post_deploy.py +++ b/scripts/post_deploy.py @@ -244,8 +244,8 @@ def get_api_headers(config: ResourceConfig) -> Dict[str, str]: return headers -async def check_admin_api_health(config: ResourceConfig, max_retries: int = 5, retry_delay: int = 10) -> bool: - """Check if the admin API is available with retry logic for cold starts.""" +async def check_admin_api_health(config: ResourceConfig, max_retries: int = 8, retry_delay: int = 20) -> bool: + """Check if the admin API is available with retry logic for cold starts and container restarts.""" print_step("Checking admin API health...") async with httpx.AsyncClient(timeout=30.0) as client: @@ -256,8 +256,32 @@ async def check_admin_api_health(config: ResourceConfig, max_retries: int = 5, r headers=get_api_headers(config) ) if response.status_code == 200: - print_success("Admin API is healthy") - return True + # Validate the response is actually from the backend admin API, + # not the frontend catch-all serving index.html + try: + data = response.json() + if data.get("status") == "healthy": + print_success("Admin API is healthy") + return True + else: + print_warning( + f"Attempt {attempt}/{max_retries}: Admin API returned unexpected JSON: {data}" + ) + except (ValueError, json.JSONDecodeError): + # Response is not JSON. + # This means the API proxy is not forwarding requests to the backend. + content_preview = response.text[:200] + is_html = " { - // Disable buffering for streaming responses - if (proxyRes.headers['content-type']?.includes('text/event-stream')) { - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.setHeader('X-Accel-Buffering', 'no'); - res.setHeader('Connection', 'keep-alive'); - res.flushHeaders(); + const apiProxy = createProxyMiddleware({ + target: BACKEND_URL, + changeOrigin: true, + pathRewrite: { + '^/': '/api/' + }, + agent: httpAgent, + // Increase timeout for long-running requests (10 minutes) + proxyTimeout: 600000, + timeout: 600000, + // Support streaming responses (SSE) + onProxyRes: (proxyRes, req, res) => { + // Disable buffering for streaming responses + if (proxyRes.headers['content-type']?.includes('text/event-stream')) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('X-Accel-Buffering', 'no'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + } + }, + onError: (err, req, res) => { + console.error('Proxy error:', err.message); + if (!res.headersSent) { + res.status(502).json({ error: 'Backend service unavailable', details: err.message }); + } } - // Log response for debugging - console.log(`Proxy response: ${req.method} ${req.path} -> ${proxyRes.statusCode}`); - }, - onProxyReq: (proxyReq, req, res) => { - // Log request for debugging - console.log(`Proxy request: ${req.method} ${req.path}`); - }, - onError: (err, req, res) => { - console.error('Proxy error:', err.message); - if (!res.headersSent) { - res.status(502).json({ error: 'Backend service unavailable', details: err.message }); - } - } -})); + }); + app.use('/api', apiProxy); +} else { + // When BACKEND_URL is not set, return a clear error for API requests + // instead of letting them fall through to the SPA catch-all (which returns index.html with 200) + app.use('/api', (req, res) => { + res.status(503).json({ + error: 'Backend not configured', + message: 'BACKEND_URL environment variable is not set. The API proxy is not available.', + }); + }); } + // Serve static files from the build directory app.use(express.static(path.join(__dirname, 'static'))); diff --git a/src/App/src/App.tsx b/src/App/src/App.tsx index 8c28e0de1..8720b5414 100644 --- a/src/App/src/App.tsx +++ b/src/App/src/App.tsx @@ -14,7 +14,7 @@ function App() { const dispatch = useAppDispatch(); // Select state from Redux store - const { userName, imageGenerationEnabled, showChatHistory } = useAppSelector(state => state.app); + const { imageGenerationEnabled, showChatHistory } = useAppSelector(state => state.app); const { conversationId, conversationTitle, messages, isLoading, generationStatus, historyRefreshTrigger } = useAppSelector(state => state.chat); const { pendingBrief, confirmedBrief, selectedProducts, availableProducts, generatedContent } = useAppSelector(state => state.content); @@ -44,7 +44,6 @@ function App() {
{/* Header */} dispatch(toggleChatHistory())} /> diff --git a/src/App/src/components/AppHeader.tsx b/src/App/src/components/AppHeader.tsx index dd7205737..1bc00d24f 100644 --- a/src/App/src/components/AppHeader.tsx +++ b/src/App/src/components/AppHeader.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { Text, - Avatar, Button, Tooltip, tokens, @@ -16,15 +15,14 @@ import { History24Filled, } from '@fluentui/react-icons'; import ContosoLogo from '../styles/images/contoso.svg'; +import LoginButton from './LoginButton'; interface AppHeaderProps { - userName: string; showChatHistory: boolean; onToggleChatHistory: () => void; } export const AppHeader = React.memo(function AppHeader({ - userName, showChatHistory, onToggleChatHistory, }: AppHeaderProps) { @@ -53,11 +51,7 @@ export const AppHeader = React.memo(function AppHeader({ aria-label={showChatHistory ? 'Hide chat history' : 'Show chat history'} /> - +
); diff --git a/src/App/src/components/ChatInput.tsx b/src/App/src/components/ChatInput.tsx index b51983af2..bed099ee9 100644 --- a/src/App/src/components/ChatInput.tsx +++ b/src/App/src/components/ChatInput.tsx @@ -122,6 +122,7 @@ export const ChatInput = React.memo(function ChatInput({ size="small" onClick={handleSubmit} disabled={!inputValue.trim() || isLoading} + aria-label="Send" style={{ minWidth: '32px', height: '32px', diff --git a/src/App/src/components/LoginButton.tsx b/src/App/src/components/LoginButton.tsx new file mode 100644 index 000000000..0753b6a44 --- /dev/null +++ b/src/App/src/components/LoginButton.tsx @@ -0,0 +1,133 @@ +import React, { useCallback } from 'react'; +import { + Avatar, + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + Button, + makeStyles, + tokens, +} from '@fluentui/react-components'; +import { Person20Regular, SignOut24Regular } from '@fluentui/react-icons'; +import { useAppSelector } from '../store/hooks'; + +const useStyles = makeStyles({ + userButton: { + minWidth: 'auto', + paddingLeft: tokens.spacingHorizontalXS, + paddingRight: tokens.spacingHorizontalXS, + }, + menuItem: { + paddingLeft: tokens.spacingHorizontalM, + paddingRight: tokens.spacingHorizontalM, + }, + userInfo: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalXXS, + }, + userName: { + fontWeight: tokens.fontWeightSemibold, + fontSize: tokens.fontSizeBase200, + }, + userEmail: { + fontSize: tokens.fontSizeBase100, + color: tokens.colorNeutralForeground2, + }, +}); + +const getUserInitials = (name: string | undefined): string => { + if (!name) return 'U'; + const cleanName = name.replace(/\s*\([^)]*\)/g, '').trim(); + if (!cleanName) return 'U'; + const parts = cleanName.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return cleanName.charAt(0).toUpperCase(); +}; + +const LoginButton: React.FC = () => { + const styles = useStyles(); + const userName = useAppSelector(state => state.app.userName); + const userId = useAppSelector(state => state.app.userId); + const userEmail = useAppSelector(state => state.app.userEmail); + const isAuthenticated = Boolean(userId && userId !== 'anonymous'); + + const login = useCallback(() => { + window.location.href = '/.auth/login/aad'; + }, []); + + const logout = useCallback(() => { + const logoutUrl = '/.auth/logout?post_logout_redirect_uri=' + encodeURIComponent('/'); + window.location.href = logoutUrl; + }, []); + + const displayName = isAuthenticated ? userName || userId || 'User' : 'User'; + + if (!isAuthenticated) { + return ( +