diff --git a/dashboard/backend/src/models.rs b/dashboard/backend/src/models.rs index fcc9ed6..ac492e1 100644 --- a/dashboard/backend/src/models.rs +++ b/dashboard/backend/src/models.rs @@ -52,12 +52,19 @@ pub struct GitHubUserInfo { // ── Previews ── +#[derive(Debug, Serialize, Clone)] +pub struct ExtraUrl { + pub label: String, + pub url: String, +} + #[derive(Debug, Serialize, Clone)] pub struct Preview { pub slug: String, pub repo: String, pub branch: String, pub url: String, + pub extra_urls: Vec, } #[derive(Debug, Deserialize)] diff --git a/dashboard/backend/src/shell.rs b/dashboard/backend/src/shell.rs index ce0b886..5b9439e 100644 --- a/dashboard/backend/src/shell.rs +++ b/dashboard/backend/src/shell.rs @@ -6,7 +6,7 @@ use tokio::sync::broadcast; use crate::config::Config; use crate::error::AppError; -use crate::models::Preview; +use crate::models::{ExtraUrl, Preview}; /// A structured action extracted from Claude's stream-json output. #[derive(Debug, Clone)] @@ -139,7 +139,34 @@ pub async fn list_previews(config: &Config) -> Result, AppError> { parse_preview_list(&clean, &config.preview_domain) } -fn parse_preview_list(output: &str, _domain: &str) -> Result, AppError> { +/// Read the `.meta` file for a preview and extract extra host URLs. +fn read_extra_urls(slug: &str, domain: &str) -> Vec { + let meta_path = format!("/var/lib/preview-deploys/{slug}.meta"); + let content = match std::fs::read_to_string(&meta_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + let meta: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let extra_hosts = match meta.get("extraHosts").and_then(|v| v.as_array()) { + Some(hosts) => hosts, + None => return Vec::new(), + }; + extra_hosts + .iter() + .filter_map(|h| { + let prefix = h.get("prefix")?.as_str()?; + Some(ExtraUrl { + label: prefix.to_string(), + url: format!("https://{prefix}-{slug}.{domain}"), + }) + }) + .collect() +} + +fn parse_preview_list(output: &str, domain: &str) -> Result, AppError> { let mut previews = Vec::new(); let preview_dir = "/var/lib/preview-deploys"; @@ -171,11 +198,14 @@ fn parse_preview_list(output: &str, _domain: &str) -> Result, AppEr }) .unwrap_or_default(); + let extra_urls = read_extra_urls(&slug, domain); + previews.push(Preview { slug, repo, branch, url, + extra_urls, }); } } @@ -812,6 +842,8 @@ SLUG STATUS BRANCH URL assert_eq!(result[0].slug, "123"); assert_eq!(result[0].branch, "feat/foo"); assert_eq!(result[0].url, "https://123.example.com"); + // extra_urls will be empty since .meta files don't exist in test + assert!(result[0].extra_urls.is_empty()); assert_eq!(result[1].slug, "456"); } diff --git a/dashboard/frontend/src/lib/api.ts b/dashboard/frontend/src/lib/api.ts index 1229586..8860ed9 100644 --- a/dashboard/frontend/src/lib/api.ts +++ b/dashboard/frontend/src/lib/api.ts @@ -4,11 +4,17 @@ export interface UserInfo { role: string; } +export interface ExtraUrl { + label: string; + url: string; +} + export interface Preview { slug: string; repo: string; branch: string; url: string; + extra_urls: ExtraUrl[]; } export interface Task { diff --git a/dashboard/frontend/src/pages/PreviewDetail.tsx b/dashboard/frontend/src/pages/PreviewDetail.tsx index 0e63137..f8869e1 100644 --- a/dashboard/frontend/src/pages/PreviewDetail.tsx +++ b/dashboard/frontend/src/pages/PreviewDetail.tsx @@ -1,8 +1,10 @@ import { useEffect, useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { ChevronLeft, ExternalLink } from 'lucide-react'; import LogViewer from '@/components/LogViewer'; -import { getConfig } from '@/lib/api'; +import { getConfig, listPreviews } from '@/lib/api'; +import type { ExtraUrl } from '@/lib/api'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -19,6 +21,13 @@ export default function PreviewDetail() { }).catch(() => {}); }, []); + const { data: previews } = useQuery({ + queryKey: ['previews'], + queryFn: listPreviews, + }); + + const extraUrls: ExtraUrl[] = previews?.find((p) => p.slug === slug)?.extra_urls ?? []; + const handleConnectionChange = useCallback((c: boolean) => setConnected(c), []); const previewUrl = slug && previewDomain ? `https://${slug}.${previewDomain}` : ''; @@ -34,12 +43,22 @@ export default function PreviewDetail() { {connected ? 'Live' : 'Disconnected'} - +
+ + {extraUrls.map((eu) => ( + + ))} +
diff --git a/dashboard/frontend/src/pages/Previews.tsx b/dashboard/frontend/src/pages/Previews.tsx index 757fd44..30620ec 100644 --- a/dashboard/frontend/src/pages/Previews.tsx +++ b/dashboard/frontend/src/pages/Previews.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; +import { ExternalLink } from 'lucide-react'; import { listPreviews, createPreview, destroyPreview, ApiError } from '@/lib/api'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -177,37 +178,55 @@ export default function Previews() { ) : (
{filteredPreviews.map((p) => ( -
-
-
- {p.slug} - {p.repo} - {p.branch} +
+
+
+
+ {p.slug} + {p.repo} + {p.branch} +
+
+
+ + +
-
- - - -
+ {p.extra_urls?.length > 0 && ( +
+ {p.extra_urls.map((eu) => ( + + {eu.label} + + + ))} +
+ )}
))}
diff --git a/dashboard/frontend/src/pages/TaskDetail.tsx b/dashboard/frontend/src/pages/TaskDetail.tsx index c445527..5001dfd 100644 --- a/dashboard/frontend/src/pages/TaskDetail.tsx +++ b/dashboard/frontend/src/pages/TaskDetail.tsx @@ -28,6 +28,7 @@ import { getTask, listSubtasks, listTaskActions, + listPreviews, getMe, parseImageUrls, reopenTask, @@ -89,6 +90,14 @@ export default function TaskDetail() { staleTime: 30_000, }); + const { data: previews } = useQuery({ + queryKey: ['previews'], + queryFn: listPreviews, + enabled: !!task?.preview_slug, + }); + + const previewExtraUrls = previews?.find((p) => p.slug === task?.preview_slug)?.extra_urls ?? []; + const onConnectionChange = useCallback((c: boolean) => setConnected(c), []); const queryClient = useQueryClient(); @@ -349,17 +358,29 @@ export default function TaskDetail() { {/* Preview tab — embedded iframe of the deployed preview */} {task?.preview_url && ( -
+
{task.preview_url} - Open in new tab + Open + {previewExtraUrls.map((eu) => ( + + + {eu.label} + + ))}