Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions dashboard/backend/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExtraUrl>,
}

#[derive(Debug, Deserialize)]
Expand Down
36 changes: 34 additions & 2 deletions dashboard/backend/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -139,7 +139,34 @@ pub async fn list_previews(config: &Config) -> Result<Vec<Preview>, AppError> {
parse_preview_list(&clean, &config.preview_domain)
}

fn parse_preview_list(output: &str, _domain: &str) -> Result<Vec<Preview>, AppError> {
/// Read the `.meta` file for a preview and extract extra host URLs.
fn read_extra_urls(slug: &str, domain: &str) -> Vec<ExtraUrl> {
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<Vec<Preview>, AppError> {
let mut previews = Vec::new();
let preview_dir = "/var/lib/preview-deploys";

Expand Down Expand Up @@ -171,11 +198,14 @@ fn parse_preview_list(output: &str, _domain: &str) -> Result<Vec<Preview>, AppEr
})
.unwrap_or_default();

let extra_urls = read_extra_urls(&slug, domain);

previews.push(Preview {
slug,
repo,
branch,
url,
extra_urls,
});
}
}
Expand Down Expand Up @@ -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");
}

Expand Down
6 changes: 6 additions & 0 deletions dashboard/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 26 additions & 7 deletions dashboard/frontend/src/pages/PreviewDetail.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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}` : '';
Expand All @@ -34,12 +43,22 @@ export default function PreviewDetail() {
<Badge variant={connected ? 'default' : 'outline'}>
{connected ? 'Live' : 'Disconnected'}
</Badge>
<Button size="sm" className="ml-auto" asChild>
<a href={previewUrl} target="_blank" rel="noopener noreferrer">
Open Preview
<ExternalLink className="ml-1 size-3" />
</a>
</Button>
<div className="flex items-center gap-2 ml-auto">
<Button size="sm" asChild>
<a href={previewUrl} target="_blank" rel="noopener noreferrer">
Open Preview
<ExternalLink className="ml-1 size-3" />
</a>
</Button>
{extraUrls.map((eu) => (
<Button key={eu.label} variant="outline" size="sm" asChild>
<a href={eu.url} target="_blank" rel="noopener noreferrer">
{eu.label}
<ExternalLink className="ml-1 size-3" />
</a>
</Button>
))}
</div>
</div>

<Card>
Expand Down
77 changes: 48 additions & 29 deletions dashboard/frontend/src/pages/Previews.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -177,37 +178,55 @@ export default function Previews() {
) : (
<div className="divide-y divide-border border rounded-md">
{filteredPreviews.map((p) => (
<div key={p.slug} className="flex items-center justify-between px-4 py-3 hover:bg-secondary/40 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<span className="font-mono text-sm font-medium">{p.slug}</span>
<Badge variant="outline">{p.repo}</Badge>
<Badge variant="outline">{p.branch}</Badge>
<div key={p.slug} className="px-4 py-3 hover:bg-secondary/40 transition-colors">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<span className="font-mono text-sm font-medium">{p.slug}</span>
<Badge variant="outline">{p.repo}</Badge>
<Badge variant="outline">{p.branch}</Badge>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Button variant="outline" size="sm" asChild>
<a href={p.url} target="_blank" rel="noopener noreferrer">
Open
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to={`/previews/${p.slug}`}>Logs</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
if (confirm(`Destroy preview "${p.slug}"?`)) {
destroyMutation.mutate(p.slug);
}
}}
disabled={destroyMutation.isPending}
>
Destroy
</Button>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Button variant="outline" size="sm" asChild>
<a href={p.url} target="_blank" rel="noopener noreferrer">
Open
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to={`/previews/${p.slug}`}>Logs</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
if (confirm(`Destroy preview "${p.slug}"?`)) {
destroyMutation.mutate(p.slug);
}
}}
disabled={destroyMutation.isPending}
>
Destroy
</Button>
</div>
{p.extra_urls?.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 ml-1">
{p.extra_urls.map((eu) => (
<a
key={eu.label}
href={eu.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{eu.label}
<ExternalLink className="size-3" />
</a>
))}
</div>
)}
</div>
))}
</div>
Expand Down
27 changes: 24 additions & 3 deletions dashboard/frontend/src/pages/TaskDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getTask,
listSubtasks,
listTaskActions,
listPreviews,
getMe,
parseImageUrls,
reopenTask,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -349,17 +358,29 @@ export default function TaskDetail() {
{/* Preview tab — embedded iframe of the deployed preview */}
{task?.preview_url && (
<TabsContent value="preview" className="flex-1 flex flex-col min-h-0 rounded-b-lg border border-t-0 border-border bg-card overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-card/50">
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card/50">
<span className="text-sm text-muted-foreground truncate">{task.preview_url}</span>
<a
href={task.preview_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline shrink-0 ml-3"
className="inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline shrink-0"
>
<ExternalLink className="size-3.5" />
Open in new tab
Open
</a>
{previewExtraUrls.map((eu) => (
<a
key={eu.label}
href={eu.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline shrink-0"
>
<ExternalLink className="size-3" />
{eu.label}
</a>
))}
</div>
<iframe
src={task.preview_url}
Expand Down
Loading