2026-04-23 16:00:44 +02:00
|
|
|
import { useRef, useState } from 'react';
|
|
|
|
|
import { LogViewer, Button } from '@cameleer/design-system';
|
2026-04-14 23:56:01 +02:00
|
|
|
import type { LogEntry } from '@cameleer/design-system';
|
2026-04-23 16:00:44 +02:00
|
|
|
import { RefreshCw } from 'lucide-react';
|
2026-04-14 23:21:26 +02:00
|
|
|
import { useStartupLogs } from '../api/queries/logs';
|
|
|
|
|
import type { Deployment } from '../api/queries/admin/apps';
|
|
|
|
|
import styles from './StartupLogPanel.module.css';
|
|
|
|
|
|
|
|
|
|
interface StartupLogPanelProps {
|
|
|
|
|
deployment: Deployment;
|
|
|
|
|
appSlug: string;
|
|
|
|
|
envSlug: string;
|
2026-04-22 23:03:52 +02:00
|
|
|
className?: string;
|
2026-04-14 23:21:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 23:03:52 +02:00
|
|
|
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
|
2026-04-14 23:21:26 +02:00
|
|
|
const isStarting = deployment.status === 'STARTING';
|
|
|
|
|
const isFailed = deployment.status === 'FAILED';
|
2026-04-23 16:00:44 +02:00
|
|
|
const [sort, setSort] = useState<'asc' | 'desc'>('desc');
|
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
2026-04-14 23:21:26 +02:00
|
|
|
|
2026-04-23 16:00:44 +02:00
|
|
|
const query = useStartupLogs(appSlug, envSlug, deployment.createdAt, isStarting, sort);
|
|
|
|
|
const entries = query.data?.data ?? [];
|
2026-04-14 23:21:26 +02:00
|
|
|
|
2026-04-23 16:00:44 +02:00
|
|
|
const scrollToLatest = () => {
|
|
|
|
|
const el = scrollRef.current;
|
2026-04-23 16:05:35 +02:00
|
|
|
if (!el || typeof el.scrollTo !== 'function') return;
|
2026-04-23 16:00:44 +02:00
|
|
|
// asc → latest at bottom; desc → latest at top
|
|
|
|
|
const top = sort === 'asc' ? el.scrollHeight : 0;
|
|
|
|
|
el.scrollTo({ top, behavior: 'smooth' });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRefresh = async () => {
|
|
|
|
|
await query.refetch?.();
|
|
|
|
|
scrollToLatest();
|
|
|
|
|
};
|
2026-04-14 23:21:26 +02:00
|
|
|
|
|
|
|
|
if (entries.length === 0 && !isStarting) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-22 23:03:52 +02:00
|
|
|
<div className={`${styles.panel}${className ? ` ${className}` : ''}`}>
|
2026-04-14 23:21:26 +02:00
|
|
|
<div className={styles.header}>
|
|
|
|
|
<div className={styles.headerLeft}>
|
|
|
|
|
<span className={styles.title}>Startup Logs</span>
|
|
|
|
|
{isStarting && (
|
|
|
|
|
<>
|
|
|
|
|
<span className={`${styles.badge} ${styles.badgeLive}`}>● live</span>
|
|
|
|
|
<span className={styles.pollingHint}>polling every 3s</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{isFailed && (
|
|
|
|
|
<span className={`${styles.badge} ${styles.badgeStopped}`}>stopped</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-23 16:00:44 +02:00
|
|
|
<div className={styles.headerRight}>
|
|
|
|
|
<span className={styles.lineCount}>{entries.length} entries</span>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
2026-04-23 16:05:35 +02:00
|
|
|
disabled={query.isFetching}
|
2026-04-23 16:00:44 +02:00
|
|
|
onClick={() => setSort((s) => (s === 'asc' ? 'desc' : 'asc'))}
|
|
|
|
|
title={sort === 'asc' ? 'Oldest first' : 'Newest first'}
|
|
|
|
|
aria-label={sort === 'asc' ? 'Oldest first' : 'Newest first'}
|
|
|
|
|
>
|
|
|
|
|
{sort === 'asc' ? '\u2191' : '\u2193'}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
2026-04-23 16:05:35 +02:00
|
|
|
disabled={query.isFetching}
|
2026-04-23 16:00:44 +02:00
|
|
|
onClick={handleRefresh}
|
|
|
|
|
title="Refresh"
|
|
|
|
|
aria-label="Refresh"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw size={14} />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div ref={scrollRef} className={styles.scrollWrap}>
|
|
|
|
|
{entries.length > 0 ? (
|
|
|
|
|
<LogViewer entries={entries as unknown as LogEntry[]} />
|
|
|
|
|
) : (
|
|
|
|
|
<div className={styles.empty}>Waiting for container output...</div>
|
|
|
|
|
)}
|
2026-04-14 23:21:26 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|