This commit is contained in:
@@ -227,7 +227,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex gap-4 -mb-px">
|
||||
<div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex flex-wrap gap-x-4 gap-y-1 -mb-px">
|
||||
{tabs.map(tab => {
|
||||
const active = activeTab === tab.id;
|
||||
const pending = active && tabTransitionPending;
|
||||
@@ -237,7 +237,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
|
||||
className={`whitespace-nowrap pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
|
||||
active
|
||||
? 'border-accent text-slate-900 font-semibold'
|
||||
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
|
||||
|
||||
@@ -104,7 +104,27 @@ export function LocalDetailPanel({
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const showSshTab = consoleStatus?.active === true;
|
||||
const visibleTabs = LOCAL_TABS.filter((t) => t.id !== 'ssh' || showSshTab);
|
||||
|
||||
// Browser tab visibility: mirror SSH — show only once a viewable browser
|
||||
// session is live for this task. `available: true` means a noVNC session
|
||||
// exists (the agent has actually used the browser); every other case
|
||||
// (no_session / headless_mode / display_unavailable / novnc_not_installed)
|
||||
// is non-viewable, so the tab would show nothing useful and stays hidden.
|
||||
// Shares the ['task-session', id] query with BrowserTab (deduped by key).
|
||||
const { data: browserSession } = useQuery<{ available: boolean }>({
|
||||
queryKey: ['task-session', task?.id],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/local/browser/sessions/task-session/${task!.id}`);
|
||||
return r.ok ? r.json() : { available: false };
|
||||
},
|
||||
enabled: !!task,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const showBrowserTab = browserSession?.available === true;
|
||||
|
||||
const visibleTabs = LOCAL_TABS.filter(
|
||||
(t) => (t.id !== 'ssh' || showSshTab) && (t.id !== 'browser' || showBrowserTab),
|
||||
);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (!task) return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief } from '../../../api';
|
||||
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief, updateLocalTask, regenerateTaskTitle } from '../../../api';
|
||||
import { StatusBadge } from '../../shared/StatusBadge';
|
||||
import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel';
|
||||
import { ContextUsageGauge } from '../ContextUsageGauge';
|
||||
@@ -272,6 +272,116 @@ function MissionCard({ task }: { task: LocalTask }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable task title with on-demand AI regeneration. The title is set to a
|
||||
* cheap fallback at creation and upgraded by the agent (derived from the
|
||||
* Mission Brief goal) during the run. A manual edit pins it (title_source =
|
||||
* 'user') so the agent never overwrites it afterwards.
|
||||
*/
|
||||
function TaskTitleRow({ task }: { task: LocalTask }) {
|
||||
const { t } = useTranslation('detail');
|
||||
const qc = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(task.title);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Sync the displayed title with server-side updates (the agent rewrites it
|
||||
// mid-run) unless the user is actively editing.
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(task.title);
|
||||
}, [task.title, editing]);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => updateLocalTask(task.id, { title: draft.trim() }),
|
||||
onSuccess: () => { setEditing(false); setError(null); invalidate(); },
|
||||
onError: (err: unknown) => setError(err instanceof Error ? err.message : 'Failed to save title'),
|
||||
});
|
||||
|
||||
const regenMutation = useMutation({
|
||||
mutationFn: () => regenerateTaskTitle(task.id),
|
||||
onSuccess: () => { setError(null); invalidate(); },
|
||||
onError: (err: unknown) => setError(err instanceof Error ? err.message : 'Failed to regenerate title'),
|
||||
});
|
||||
|
||||
const save = () => { if (draft.trim()) saveMutation.mutate(); };
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
value={draft}
|
||||
autoFocus
|
||||
maxLength={200}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); save(); }
|
||||
if (e.key === 'Escape') { setEditing(false); setError(null); setDraft(task.title); }
|
||||
}}
|
||||
className="w-full px-2.5 py-1.5 text-lg font-extrabold text-slate-900 border border-hairline rounded-md focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent"
|
||||
/>
|
||||
{error && <div className="text-2xs text-red-600">{error}</div>}
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setError(null); setDraft(task.title); }}
|
||||
disabled={saveMutation.isPending}
|
||||
className="px-3 h-7 text-xs rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t('title.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saveMutation.isPending || !draft.trim()}
|
||||
className="px-3 h-7 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? t('title.saving') : t('title.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group flex items-start justify-between gap-2">
|
||||
<div className="text-lg font-extrabold text-slate-900 break-words leading-tight min-w-0">{task.title}</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => regenMutation.mutate()}
|
||||
disabled={regenMutation.isPending}
|
||||
title={t('title.regenerate')}
|
||||
aria-label={t('title.regenerate')}
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-accent hover:bg-surface transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg className={`w-3.5 h-3.5 ${regenMutation.isPending ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2v3h-3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDraft(task.title); setEditing(true); setError(null); }}
|
||||
title={t('title.edit')}
|
||||
aria-label={t('title.edit')}
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11.5 2.5l2 2L6 12l-2.5.5L4 10l7.5-7.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="text-2xs text-red-600 mt-1">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewTabProps {
|
||||
task: LocalTask;
|
||||
subtaskActivities?: SubtaskActivity[];
|
||||
@@ -284,7 +394,7 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="text-lg font-extrabold text-slate-900">{task.title}</div>
|
||||
<TaskTitleRow task={task} />
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<StatusBadge status={status} />
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
|
||||
import { isPreviewable, formatFileDate } from '../../lib/utils';
|
||||
import { splitFileName } from '../../lib/fileType';
|
||||
import { FileTypeIcon } from './FileTypeIcon';
|
||||
|
||||
interface FileBrowserProps {
|
||||
section: 'workspace' | 'input' | 'output' | 'logs';
|
||||
@@ -211,20 +213,30 @@ export function FileBrowser({
|
||||
key={`${entry.kind}:${entry.path}`}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-canvas border border-hairline hover:bg-surface transition-colors"
|
||||
>
|
||||
<span className="text-slate-400 flex-shrink-0" aria-hidden="true">
|
||||
<span className="flex-shrink-0" aria-hidden="true">
|
||||
{entry.kind === 'directory' ? (
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="w-4 h-4 text-slate-400" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
|
||||
<path d="M9 2v4.5h4.5" />
|
||||
</svg>
|
||||
<FileTypeIcon name={entry.name} />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div>
|
||||
{entry.kind === 'file'
|
||||
? (() => {
|
||||
// Truncate the stem but never the extension, so the file
|
||||
// type stays readable on long names: `long-na…me.xlsx`.
|
||||
const { stem, ext } = splitFileName(entry.name);
|
||||
return (
|
||||
<div className="flex items-baseline text-[13px] text-slate-800 min-w-0" title={entry.name}>
|
||||
<span className="truncate min-w-0">{stem}</span>
|
||||
{ext && <span className="flex-shrink-0">{ext}</span>}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: <div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div>
|
||||
}
|
||||
{entry.kind === 'file' && entry.modifiedAt && (
|
||||
<div className="text-[10px] text-slate-400 font-mono leading-tight">{formatFileDate(entry.modifiedAt)}</div>
|
||||
)}
|
||||
|
||||
@@ -65,6 +65,7 @@ interface FilePreviewProps {
|
||||
section?: string;
|
||||
filePath?: string;
|
||||
editable?: boolean;
|
||||
trustedHtmlUrl?: string;
|
||||
}
|
||||
|
||||
// --- CSV ---
|
||||
@@ -622,7 +623,7 @@ function renderJsonl(content: string): JSX.Element {
|
||||
}
|
||||
|
||||
// --- FilePreview ---
|
||||
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable }: FilePreviewProps) {
|
||||
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable, trustedHtmlUrl }: FilePreviewProps) {
|
||||
const { t } = useTranslation('files');
|
||||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||
const [editContent, setEditContent] = useState(content);
|
||||
@@ -634,6 +635,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
|
||||
const canEdit = editable && taskId != null && section && filePath;
|
||||
const isMarkdownFile = /\.(md|markdown)$/i.test(name);
|
||||
const canOpenTrustedHtml = mode === 'view' && !!trustedHtmlUrl && /\.html?$/i.test(name);
|
||||
|
||||
const handlePrint = async () => {
|
||||
if (printing) return;
|
||||
@@ -756,6 +758,21 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-hairline flex-shrink-0 sticky top-0 bg-surface z-10 gap-2">
|
||||
<div className="font-mono text-xs text-slate-700 truncate" title={name}>{name}</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{canOpenTrustedHtml && (
|
||||
<a
|
||||
href={trustedHtmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={t('trustedHtml.tooltip')}
|
||||
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 3H3.5A1.5 1.5 0 0 0 2 4.5v8A1.5 1.5 0 0 0 3.5 14h8a1.5 1.5 0 0 0 1.5-1.5V10" />
|
||||
<path d="M9 2h5v5M8 8l6-6" />
|
||||
</svg>
|
||||
{t('trustedHtml.button')}
|
||||
</a>
|
||||
)}
|
||||
{isMarkdownFile && mode === 'view' && (
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { fileCategory, categoryColorClass, type FileCategory } from '../../lib/fileType';
|
||||
|
||||
/** カテゴリごとの 16x16 ストロークグリフ (既存ファイル行のアイコンと同じスタイル)。 */
|
||||
function glyph(cat: FileCategory) {
|
||||
switch (cat) {
|
||||
case 'image':
|
||||
return (
|
||||
<>
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5" />
|
||||
<circle cx="5.5" cy="6.5" r="1" />
|
||||
<path d="M3 12l3-3 2.5 2.5L11 7l2 2" />
|
||||
</>
|
||||
);
|
||||
case 'spreadsheet':
|
||||
return (
|
||||
<>
|
||||
<rect x="2.5" y="3" width="11" height="10" rx="1" />
|
||||
<path d="M2.5 6.5h11M2.5 10h11M6 3v10M10 3v10" />
|
||||
</>
|
||||
);
|
||||
case 'document':
|
||||
return (
|
||||
<>
|
||||
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
|
||||
<path d="M9 2v4.5h4.5" />
|
||||
<path d="M5.5 9h5M5.5 11h3" />
|
||||
</>
|
||||
);
|
||||
case 'presentation':
|
||||
return (
|
||||
<>
|
||||
<rect x="2" y="3" width="12" height="8" rx="1" />
|
||||
<path d="M8 11v2M6 14h4" />
|
||||
</>
|
||||
);
|
||||
case 'code':
|
||||
return <path d="M6 5L3 8l3 3M10 5l3 3-3 3" />;
|
||||
case 'archive':
|
||||
return (
|
||||
<>
|
||||
<rect x="3" y="3" width="10" height="11" rx="1" />
|
||||
<path d="M8 3v11" strokeDasharray="1.5 1.5" />
|
||||
</>
|
||||
);
|
||||
case 'audio':
|
||||
return (
|
||||
<>
|
||||
<circle cx="5.3" cy="11.5" r="1.7" />
|
||||
<circle cx="10.7" cy="9.7" r="1.7" />
|
||||
<path d="M7 11.5V4l5.4-1.5v7.2" />
|
||||
</>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<>
|
||||
<rect x="2" y="3.5" width="12" height="9" rx="1.5" />
|
||||
<path d="M7 6.3l3 1.7-3 1.7z" />
|
||||
</>
|
||||
);
|
||||
case 'pdf':
|
||||
case 'other':
|
||||
default:
|
||||
// 折れ角のあるページ。pdf は色 (赤) で識別、other は灰色。
|
||||
return (
|
||||
<>
|
||||
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
|
||||
<path d="M9 2v4.5h4.5" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function FileTypeIcon({ name, className }: { name: string; className?: string }) {
|
||||
const cat = fileCategory(name);
|
||||
return (
|
||||
<svg
|
||||
className={`${className ?? 'w-4 h-4'} ${categoryColorClass(cat)}`}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{glyph(cat)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
export interface NavItem {
|
||||
id: PageId;
|
||||
label: string;
|
||||
/** i18n key resolved against the `layout` namespace at render time. */
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
interface NavDrawerProps {
|
||||
@@ -85,6 +86,14 @@ const NAV_ICONS: Record<PageId, ReactNode> = {
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
||||
</svg>
|
||||
),
|
||||
usage: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<line x1="4" y1="20" x2="20" y2="20" />
|
||||
<rect x="6" y="11" width="3" height="6" />
|
||||
<rect x="11" y="7" width="3" height="10" />
|
||||
<rect x="16" y="13" width="3" height="4" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export function NavDrawer({
|
||||
@@ -211,7 +220,7 @@ export function NavDrawer({
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span>
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
<span className="flex-1 text-left">{t(item.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PageId } from '../../lib/urlState';
|
||||
import type { AuthUser } from '../../App';
|
||||
@@ -17,6 +17,9 @@ interface TopBarProps {
|
||||
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
|
||||
navDrawerOpen?: boolean;
|
||||
onOpenCommandK?: () => void;
|
||||
/** Reports the measured compact state up so App can drive the nav drawer /
|
||||
* edge-swipe. Called whenever the collapse decision flips. */
|
||||
onCompactChange?: (compact: boolean) => void;
|
||||
}
|
||||
|
||||
// labelKey resolves against the `layout` i18n namespace at render time (module
|
||||
@@ -26,14 +29,29 @@ export const NAV_ITEMS: Array<{ id: PageId; labelKey: string; adminOnly: boolean
|
||||
{ id: 'schedules', labelKey: 'nav.schedules', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'pieces', labelKey: 'nav.pieces', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'captcha', labelKey: 'nav.captcha', adminOnly: true, requiresAuth: false },
|
||||
{ id: 'usage', labelKey: 'nav.usage', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'settings', labelKey: 'nav.settings', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'users', labelKey: 'nav.users', adminOnly: true, requiresAuth: true },
|
||||
{ id: 'help', labelKey: 'nav.help', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'userfolder', labelKey: 'nav.userfolder', adminOnly: false, requiresAuth: false },
|
||||
];
|
||||
|
||||
export function estimateCollapseThreshold(navCount: number): number {
|
||||
return 430 + navCount * 78 + 60;
|
||||
/**
|
||||
* Decide whether the inline nav must collapse into the hamburger, from MEASURED
|
||||
* widths (not a width estimate). Collapse exactly when the nav's natural width
|
||||
* no longer fits the space left after the fixed left content (logo/name/version)
|
||||
* and the right-side controls. The buffer keeps a small gap so tabs never touch
|
||||
* the controls. Before anything is measured (zero widths) we assume it fits, so
|
||||
* the bar paints with tabs rather than flashing the hamburger.
|
||||
*/
|
||||
export function shouldCollapseNav(
|
||||
rowWidth: number,
|
||||
fixedWidth: number,
|
||||
navWidth: number,
|
||||
buffer = 40,
|
||||
): boolean {
|
||||
if (rowWidth <= 0 || navWidth <= 0) return false;
|
||||
return navWidth > rowWidth - fixedWidth - buffer;
|
||||
}
|
||||
|
||||
export function useViewportNarrow(threshold: number): boolean {
|
||||
@@ -58,11 +76,6 @@ export function visibleNavItemsFor(isAdmin: boolean, authEnabled: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompactNav(isAdmin: boolean, authEnabled: boolean): boolean {
|
||||
const visible = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
return useViewportNarrow(estimateCollapseThreshold(visible.length));
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
@@ -75,12 +88,43 @@ export function TopBar({
|
||||
hamburgerButtonRef,
|
||||
navDrawerOpen = false,
|
||||
onOpenCommandK,
|
||||
onCompactChange,
|
||||
}: TopBarProps) {
|
||||
const { t } = useTranslation('layout');
|
||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
|
||||
const [showPwChange, setShowPwChange] = useState(false);
|
||||
|
||||
// Collapse-to-hamburger decision from MEASURED widths, so it flips exactly
|
||||
// when the tabs stop fitting — no width estimate, no 2-line state.
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const leftFixedRef = useRef<HTMLDivElement>(null); // logo + appName + version (NOT the nav)
|
||||
const rightRef = useRef<HTMLDivElement>(null); // right-side controls
|
||||
const navRulerRef = useRef<HTMLDivElement>(null); // off-screen full-width nav, always present
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
if (!row || typeof ResizeObserver === 'undefined') return;
|
||||
const measure = () => {
|
||||
const leftFixed = leftFixedRef.current?.offsetWidth ?? 0;
|
||||
const right = rightRef.current?.offsetWidth ?? 0;
|
||||
const navWidth = navRulerRef.current?.offsetWidth ?? 0;
|
||||
setCompactMode(shouldCollapseNav(row.clientWidth, leftFixed + right, navWidth));
|
||||
};
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(row);
|
||||
// Observe the ruler + right group too: their widths change with language
|
||||
// (label lengths) and login state, which also moves the collapse point.
|
||||
if (navRulerRef.current) ro.observe(navRulerRef.current);
|
||||
if (rightRef.current) ro.observe(rightRef.current);
|
||||
measure();
|
||||
return () => ro.disconnect();
|
||||
}, [visibleNav.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
onCompactChange?.(compactMode);
|
||||
}, [compactMode, onCompactChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 bg-canvas border-b border-hairline px-4 flex items-center"
|
||||
@@ -89,7 +133,26 @@ export function TopBar({
|
||||
minHeight: 'calc(48px + env(safe-area-inset-top, 0px))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap w-full py-1.5">
|
||||
<div ref={rowRef} className="relative flex items-center justify-between gap-3 flex-nowrap w-full py-1.5 min-w-0">
|
||||
{/* Off-screen full-width nav ruler. Always rendered (absolute, out of
|
||||
flow) so its width is measurable even while the visible nav is
|
||||
collapsed — that constant width is what keeps the collapse decision
|
||||
from oscillating. INVARIANT: this ruler must stay a width-SUPERSET
|
||||
of the real nav (same px/gap, every label font-semibold = the widest
|
||||
state). If it ever underestimates the real nav, the 2-line bug
|
||||
returns. Do NOT add per-item active/inactive weight here. */}
|
||||
<nav
|
||||
ref={navRulerRef}
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-0 invisible pointer-events-none flex gap-5 ml-2"
|
||||
>
|
||||
{visibleNav.map(item => (
|
||||
<span key={item.id} className="px-0.5 text-xs font-semibold whitespace-nowrap">
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-4 min-w-0 self-stretch">
|
||||
{compactMode && (
|
||||
<button
|
||||
@@ -109,20 +172,25 @@ export function TopBar({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<img
|
||||
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
|
||||
alt=""
|
||||
className="flex-shrink-0 h-[22px] w-auto max-w-[140px] object-contain"
|
||||
/>
|
||||
<span className="text-xs font-semibold tracking-tight text-slate-900 hidden sm:inline">
|
||||
{appName}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
{/* leftFixedRef must wrap ONLY logo/name/version — the hamburger is a
|
||||
sibling above, kept out of this measurement so toggling it doesn't
|
||||
change leftFixed width (which would oscillate the collapse). */}
|
||||
<div ref={leftFixedRef} className="flex items-center gap-4 flex-shrink-0">
|
||||
<img
|
||||
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
|
||||
alt=""
|
||||
className="flex-shrink-0 h-[22px] w-auto max-w-[140px] object-contain"
|
||||
/>
|
||||
<span className="text-xs font-semibold tracking-tight text-slate-900 hidden sm:inline">
|
||||
{appName}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!compactMode && (
|
||||
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label={t('nav.mainNav')}>
|
||||
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2 flex-shrink-0" aria-label={t('nav.mainNav')}>
|
||||
{visibleNav.map(item => {
|
||||
const active = currentPage === item.id;
|
||||
return (
|
||||
@@ -131,7 +199,7 @@ export function TopBar({
|
||||
type="button"
|
||||
onClick={() => onNavigate(item.id)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`relative px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
className={`relative whitespace-nowrap px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
active
|
||||
? 'font-semibold text-slate-900 border-accent'
|
||||
: 'font-medium text-slate-500 border-transparent hover:text-slate-800'
|
||||
@@ -145,7 +213,7 @@ export function TopBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div ref={rightRef} className="flex items-center gap-2 flex-shrink-0">
|
||||
{onOpenCommandK && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shouldCollapseNav } from './TopBar';
|
||||
|
||||
describe('shouldCollapseNav', () => {
|
||||
it('does not collapse while widths are unmeasured (avoids hamburger flash)', () => {
|
||||
expect(shouldCollapseNav(0, 0, 0)).toBe(false);
|
||||
expect(shouldCollapseNav(1000, 300, 0)).toBe(false); // navWidth 0 = not measured yet
|
||||
});
|
||||
|
||||
it('keeps tabs when the nav fits the leftover space (minus buffer)', () => {
|
||||
// row 1000, fixed 300 → 700 available, minus 40 buffer = 660. nav 600 fits.
|
||||
expect(shouldCollapseNav(1000, 300, 600)).toBe(false);
|
||||
});
|
||||
|
||||
it('collapses when the nav no longer fits', () => {
|
||||
// 700 available - 40 buffer = 660. nav 700 does not fit.
|
||||
expect(shouldCollapseNav(1000, 300, 700)).toBe(true);
|
||||
});
|
||||
|
||||
it('respects the buffer at the boundary', () => {
|
||||
// available 660 after buffer; nav exactly 660 fits, 661 collapses.
|
||||
expect(shouldCollapseNav(1000, 300, 660)).toBe(false);
|
||||
expect(shouldCollapseNav(1000, 300, 661)).toBe(true);
|
||||
});
|
||||
|
||||
it('honors a custom buffer', () => {
|
||||
expect(shouldCollapseNav(1000, 300, 690, 10)).toBe(false); // 690 <= 690
|
||||
expect(shouldCollapseNav(1000, 300, 691, 10)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ interface TaskListPanelProps {
|
||||
onSelectTask: (id: number) => void;
|
||||
onOpenCreate: () => void;
|
||||
/**
|
||||
* Owner scope (mine/all). Only meaningful when scopeEnabled — auth must be
|
||||
* Owner scope (mine/others). Only meaningful when scopeEnabled — auth must be
|
||||
* on and the viewer known; otherwise everything is owner 'local' and the
|
||||
* control is hidden.
|
||||
*/
|
||||
@@ -36,12 +36,12 @@ interface TaskListPanelProps {
|
||||
function ScopeToggle({
|
||||
scope,
|
||||
mineCount,
|
||||
allCount,
|
||||
othersCount,
|
||||
onScopeChange,
|
||||
}: {
|
||||
scope: TaskScope;
|
||||
mineCount: number;
|
||||
allCount: number;
|
||||
othersCount: number;
|
||||
onScopeChange: (scope: TaskScope) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('list');
|
||||
@@ -62,7 +62,7 @@ function ScopeToggle({
|
||||
return (
|
||||
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label={t('scope.aria')}>
|
||||
{seg('mine', t('scope.mine'), mineCount)}
|
||||
{seg('all', t('scope.all'), allCount)}
|
||||
{seg('others', t('scope.others'), othersCount)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,8 +88,11 @@ export function TaskListPanel({
|
||||
const { t } = useTranslation('list');
|
||||
// Owner scope is the outermost filter: status counts / search / sort all
|
||||
// operate on the scoped list so "自分" mode never counts others' tasks.
|
||||
const effectiveScope: TaskScope = scopeEnabled ? scope : 'all';
|
||||
const localTasks = filterTasksByScope(allTasks, effectiveScope, currentUserId);
|
||||
// When the toggle is disabled (no-auth: every task is owner 'local'), the
|
||||
// scope partition is meaningless — show the full list unfiltered.
|
||||
const localTasks = scopeEnabled
|
||||
? filterTasksByScope(allTasks, scope, currentUserId)
|
||||
: allTasks;
|
||||
if (mode === 'rail') {
|
||||
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
|
||||
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
|
||||
@@ -169,7 +172,7 @@ export function TaskListPanel({
|
||||
<ScopeToggle
|
||||
scope={scope}
|
||||
mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length}
|
||||
allCount={allTasks.length}
|
||||
othersCount={filterTasksByScope(allTasks, 'others', currentUserId).length}
|
||||
onScopeChange={onScopeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { NotificationsForm } from './NotificationsForm';
|
||||
import { BrandingForm } from './BrandingForm';
|
||||
import { MemoryLearningForm } from './MemoryLearningForm';
|
||||
import { MetricsForm } from './MetricsForm';
|
||||
import { ServerTlsForm } from './ServerTlsForm';
|
||||
import { ReflectionForm } from './ReflectionForm';
|
||||
import { McpForm } from './McpForm';
|
||||
import { SshForm } from './SshForm';
|
||||
@@ -195,6 +196,9 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
case 'gateway-server': return <GatewayServerForm {...formProps} />;
|
||||
case 'llm-metrics': return <MetricsForm {...formProps} />;
|
||||
|
||||
// ── Server / Network
|
||||
case 'server-tls': return <ServerTlsForm {...formProps} />;
|
||||
|
||||
// ── Agent Runtime
|
||||
case 'ask-subtasks': return <AskSubtasksForm {...formProps} />;
|
||||
case 'context': return <ContextForm {...formProps} />;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Server TLS settings — binds to the `server.tls` config object.
|
||||
*
|
||||
* Editable fields: enabled, certFile, keyFile, httpRedirect,
|
||||
* httpRedirectPort, redirectHost, selfSignedHosts.
|
||||
*
|
||||
* Fields intentionally omitted from the UI (left to config.yaml):
|
||||
* minVersion, selfSignedDir. The onChange path mechanism in ConfigFormInner
|
||||
* uses setNestedValue which does a shallow-merge, so unedited fields are
|
||||
* preserved on save automatically.
|
||||
*
|
||||
* TODO(server-tls-info): cert source/expiry/fingerprint panel + regenerate
|
||||
* button need a GET /api/server/tls-info endpoint (future).
|
||||
*/
|
||||
export function ServerTlsForm({ config, onChange }: SectionFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
// Navigate to the server.tls sub-object; fall back to empty object if absent.
|
||||
const tls = (config?.server?.tls) ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||
{t('serverTls.title')}
|
||||
</h2>
|
||||
|
||||
{/* Restart-required banner — always visible */}
|
||||
<div className="px-3 py-2.5 rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-500/10 dark:border-amber-500/30 text-xs text-amber-800 dark:text-amber-300">
|
||||
{t('serverTls.restartBanner')}
|
||||
</div>
|
||||
|
||||
{/* Enable HTTPS */}
|
||||
<div>
|
||||
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700 dark:text-slate-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tls.enabled === true}
|
||||
onChange={e => onChange('server.tls.enabled', e.target.checked)}
|
||||
/>
|
||||
<span>{t('serverTls.enabled')}</span>
|
||||
</label>
|
||||
<HelpText>{t('serverTls.enabledHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{/* Certificate file */}
|
||||
<div>
|
||||
<FieldLabel>{t('serverTls.certFile')}</FieldLabel>
|
||||
<FieldInput
|
||||
value={tls.certFile ?? ''}
|
||||
onChange={v => onChange('server.tls.certFile', v || undefined)}
|
||||
placeholder="/etc/ssl/certs/server.pem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Private key file */}
|
||||
<div>
|
||||
<FieldLabel>{t('serverTls.keyFile')}</FieldLabel>
|
||||
<FieldInput
|
||||
value={tls.keyFile ?? ''}
|
||||
onChange={v => onChange('server.tls.keyFile', v || undefined)}
|
||||
placeholder="/etc/ssl/private/server.key"
|
||||
/>
|
||||
<HelpText>{t('serverTls.certHelp')}</HelpText>
|
||||
</div>
|
||||
|
||||
{/* HTTP → HTTPS redirect */}
|
||||
<div>
|
||||
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700 dark:text-slate-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tls.httpRedirect === true}
|
||||
onChange={e => onChange('server.tls.httpRedirect', e.target.checked)}
|
||||
/>
|
||||
<span>{t('serverTls.httpRedirect')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* HTTP redirect port */}
|
||||
<div>
|
||||
<FieldLabel>{t('serverTls.httpRedirectPort')}</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={tls.httpRedirectPort != null ? String(tls.httpRedirectPort) : ''}
|
||||
onChange={v => {
|
||||
const n = parseInt(v, 10);
|
||||
onChange('server.tls.httpRedirectPort', isNaN(n) ? undefined : n);
|
||||
}}
|
||||
placeholder="9080"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Redirect host (optional) */}
|
||||
<div>
|
||||
<FieldLabel>{t('serverTls.redirectHost')}</FieldLabel>
|
||||
<FieldInput
|
||||
value={tls.redirectHost ?? ''}
|
||||
onChange={v => onChange('server.tls.redirectHost', v || undefined)}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional SAN hostnames for self-signed cert */}
|
||||
<div>
|
||||
<FieldLabel>{t('serverTls.selfSignedHosts')}</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={tls.selfSignedHosts ?? []}
|
||||
onChange={v => onChange('server.tls.selfSignedHosts', v)}
|
||||
placeholder="example.com / 10.0.0.10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,13 @@ const CONFIG_GROUPS = [
|
||||
{ id: 'ssh', label: 'Admin SSH' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Network',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'server-tls', label: 'HTTPS / TLS' },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getUsageDaily, type UsageBucket, type UsageCounters } from '../../api';
|
||||
|
||||
type Preset = 'last7' | 'last30' | 'last90' | 'ytd' | 'custom';
|
||||
type Gran = 'day' | 'week' | 'month';
|
||||
|
||||
function utcToday(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
function shiftDay(day: string, delta: number): string {
|
||||
const x = new Date(`${day}T00:00:00.000Z`);
|
||||
x.setUTCDate(x.getUTCDate() + delta);
|
||||
return x.toISOString().slice(0, 10);
|
||||
}
|
||||
function yearStart(): string {
|
||||
return `${new Date().getUTCFullYear()}-01-01`;
|
||||
}
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
function total(c: UsageCounters): number {
|
||||
return c.tokensIn + c.tokensOut;
|
||||
}
|
||||
function zeroCounters(): UsageCounters {
|
||||
return { tokensIn: 0, tokensOut: 0, requests: 0 };
|
||||
}
|
||||
|
||||
// Mirror the server's bucket keys (usage-api.ts) so we can fill empty buckets
|
||||
// and keep the chart's time axis linear instead of index-based.
|
||||
function isoWeekKey(day: string): string {
|
||||
const d = new Date(`${day}T00:00:00.000Z`);
|
||||
const dayNum = (d.getUTCDay() + 6) % 7;
|
||||
d.setUTCDate(d.getUTCDate() - dayNum + 3);
|
||||
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
|
||||
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
|
||||
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
|
||||
const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86_400_000));
|
||||
return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
||||
}
|
||||
function bucketKey(day: string, g: Gran): string {
|
||||
if (g === 'month') return day.slice(0, 7);
|
||||
if (g === 'week') return isoWeekKey(day);
|
||||
return day;
|
||||
}
|
||||
/** Ordered, gap-free bucket list across [from, to] at the chosen granularity. */
|
||||
function denseBuckets(series: UsageBucket[], from: string, to: string, g: Gran): UsageBucket[] {
|
||||
const byKey = new Map(series.map((b) => [b.bucket, b]));
|
||||
const out: UsageBucket[] = [];
|
||||
const seen = new Set<string>();
|
||||
let day = from;
|
||||
let guard = 0;
|
||||
while (day <= to && guard++ < 2000) {
|
||||
const key = bucketKey(day, g);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
out.push(byKey.get(key) ?? { bucket: key, gateway: zeroCounters(), direct: zeroCounters() });
|
||||
}
|
||||
day = shiftDay(day, 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rangeFor(preset: Preset, customFrom: string, customTo: string): { from: string; to: string } {
|
||||
const to = utcToday();
|
||||
switch (preset) {
|
||||
case 'last7': return { from: shiftDay(to, -6), to };
|
||||
case 'last90': return { from: shiftDay(to, -89), to };
|
||||
case 'ytd': return { from: yearStart(), to };
|
||||
case 'custom': return { from: customFrom || shiftDay(to, -29), to: customTo || to };
|
||||
case 'last30':
|
||||
default: return { from: shiftDay(to, -29), to };
|
||||
}
|
||||
}
|
||||
|
||||
const GW = '#6366f1'; // indigo-500 — gateway
|
||||
const DR = '#22c55e'; // green-500 — direct
|
||||
|
||||
/** i18next t with interpolation support (widened from the bare key signature). */
|
||||
type TFn = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
export function UsagePage() {
|
||||
const { t } = useTranslation('usage');
|
||||
const [preset, setPreset] = useState<Preset>('last30');
|
||||
const [granularity, setGranularity] = useState<Gran>('day');
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
|
||||
const { from, to } = rangeFor(preset, customFrom, customTo);
|
||||
// Client-side guard: don't fire a request the server would 400 on; show an
|
||||
// inline message instead of a generic error.
|
||||
const customInvalid = preset === 'custom' && !!customFrom && !!customTo && customFrom > customTo;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['usage-daily', from, to, granularity],
|
||||
queryFn: () => getUsageDaily({ from, to, granularity }),
|
||||
enabled: !customInvalid,
|
||||
});
|
||||
|
||||
const presets: Preset[] = ['last7', 'last30', 'last90', 'ytd', 'custom'];
|
||||
const grans: Gran[] = ['day', 'week', 'month'];
|
||||
|
||||
const series: UsageBucket[] = data?.series ?? [];
|
||||
// Gap-free buckets so the bar/line x-axis is time-linear, not index-based.
|
||||
const dense = useMemo(
|
||||
() => (data ? denseBuckets(series, data.from, data.to, granularity) : []),
|
||||
[series, data, granularity],
|
||||
);
|
||||
const maxBucket = useMemo(
|
||||
() => dense.reduce((m, b) => Math.max(m, total(b.gateway) + total(b.direct)), 0),
|
||||
[dense],
|
||||
);
|
||||
const cumulative = useMemo(() => {
|
||||
let run = 0;
|
||||
return dense.map((b) => {
|
||||
run += total(b.gateway) + total(b.direct);
|
||||
return run;
|
||||
});
|
||||
}, [dense]);
|
||||
const maxCumulative = cumulative.length ? cumulative[cumulative.length - 1] : 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-5">
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold text-slate-800 dark:text-slate-100">{t('title')}</h1>
|
||||
<p className="text-[13px] text-slate-500 dark:text-slate-400 mt-1">{t('subtitle')}</p>
|
||||
</header>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex gap-1">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPreset(p)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md border transition-colors ${
|
||||
preset === p
|
||||
? 'bg-accent text-white border-accent'
|
||||
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{t(`range.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{preset === 'custom' && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<input type="date" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)}
|
||||
aria-invalid={customInvalid}
|
||||
className="border border-hairline rounded px-1.5 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 [color-scheme:light] dark:[color-scheme:dark]" />
|
||||
<span className="text-slate-400">→</span>
|
||||
<input type="date" value={customTo} onChange={(e) => setCustomTo(e.target.value)}
|
||||
aria-invalid={customInvalid}
|
||||
className="border border-hairline rounded px-1.5 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 [color-scheme:light] dark:[color-scheme:dark]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<span className="text-xs text-slate-400 mr-1">{t('granularity.label')}</span>
|
||||
{grans.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setGranularity(g)}
|
||||
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
||||
granularity === g
|
||||
? 'bg-accent text-white border-accent'
|
||||
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{t(`granularity.${g}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customInvalid && <div className="text-sm text-amber-600 dark:text-amber-400">{t('range.invalid')}</div>}
|
||||
{!customInvalid && isLoading && <div className="text-sm text-slate-400 italic">{t('loading')}</div>}
|
||||
{!customInvalid && error && <div className="text-sm text-red-600">{t('error')}</div>}
|
||||
|
||||
{!customInvalid && data && (
|
||||
<>
|
||||
<TotalsCards totals={data.totals} t={t} />
|
||||
|
||||
{series.length === 0 ? (
|
||||
<div className="border border-hairline rounded-lg p-8 text-center text-sm text-slate-400 italic">
|
||||
{t('empty')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StackedBars series={dense} maxBucket={maxBucket} t={t} />
|
||||
<CumulativeLine series={dense} cumulative={cumulative} max={maxCumulative} t={t} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.byUser && data.byUser.length > 0 && <ByUserTable rows={data.byUser} t={t} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalsCards({ totals, t }: { totals: { gateway: UsageCounters; direct: UsageCounters }; t: TFn }) {
|
||||
const combined: UsageCounters = {
|
||||
tokensIn: totals.gateway.tokensIn + totals.direct.tokensIn,
|
||||
tokensOut: totals.gateway.tokensOut + totals.direct.tokensOut,
|
||||
requests: totals.gateway.requests + totals.direct.requests,
|
||||
};
|
||||
const card = (label: string, c: UsageCounters, dot?: string) => (
|
||||
<div className="border border-hairline rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{dot && <span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: dot }} />}
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400">{t('totals.input')}</div>
|
||||
<div className="font-mono">{fmtTokens(c.tokensIn)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400">{t('totals.output')}</div>
|
||||
<div className="font-mono">{fmtTokens(c.tokensOut)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400">{t('totals.requests')}</div>
|
||||
<div className="font-mono">{c.requests.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{card(t('totals.combined'), combined)}
|
||||
{card(t('totals.gateway'), totals.gateway, GW)}
|
||||
{card(t('totals.direct'), totals.direct, DR)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StackedBars({ series, maxBucket, t }: { series: UsageBucket[]; maxBucket: number; t: TFn }) {
|
||||
const grand = series.reduce((s, b) => s + total(b.gateway) + total(b.direct), 0);
|
||||
const ariaLabel = t('chart.barsAria', { title: t('chart.tokensTitle'), total: fmtTokens(grand), count: series.length });
|
||||
return (
|
||||
<div className="border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.tokensTitle')}</span>
|
||||
<Legend t={t} />
|
||||
</div>
|
||||
<div className="flex items-end gap-1 h-44" role="img" aria-label={ariaLabel}>
|
||||
{series.map((b) => {
|
||||
const g = total(b.gateway);
|
||||
const d = total(b.direct);
|
||||
const sum = g + d;
|
||||
const hPct = maxBucket > 0 ? (sum / maxBucket) * 100 : 0;
|
||||
const gPct = sum > 0 ? (g / sum) * 100 : 0;
|
||||
return (
|
||||
<div key={b.bucket} className="flex-1 min-w-0 flex flex-col items-center group relative">
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: '100%' }}>
|
||||
<div className="w-full rounded-t-sm overflow-hidden flex flex-col" style={{ height: `${Math.max(hPct, sum > 0 ? 2 : 0)}%` }}>
|
||||
<div style={{ height: `${gPct}%`, background: GW }} />
|
||||
<div style={{ height: `${100 - gPct}%`, background: DR }} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Tooltip (decorative — chart summary is exposed via aria-label) */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute bottom-full mb-1 hidden group-hover:block z-10 whitespace-nowrap bg-slate-800 text-white text-[10px] rounded px-1.5 py-1 shadow">
|
||||
<div className="font-mono">{b.bucket}</div>
|
||||
<div><span style={{ color: GW }}>■</span> {fmtTokens(g)}</div>
|
||||
<div><span style={{ color: DR }}>■</span> {fmtTokens(d)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-[10px] text-slate-400 font-mono">
|
||||
<span>{series[0]?.bucket}</span>
|
||||
<span>{series[series.length - 1]?.bucket}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CumulativeLine({ series, cumulative, max, t }: { series: UsageBucket[]; cumulative: number[]; max: number; t: TFn }) {
|
||||
const W = 600;
|
||||
const H = 140;
|
||||
const pad = 4;
|
||||
const n = cumulative.length;
|
||||
const coords = cumulative.map((v, i) => {
|
||||
const x = n <= 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad);
|
||||
const y = max > 0 ? H - pad - (v / max) * (H - 2 * pad) : H - pad;
|
||||
return { x, y };
|
||||
});
|
||||
const points = coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' ');
|
||||
const ariaLabel = t('chart.lineAria', { total: fmtTokens(max) });
|
||||
return (
|
||||
<div className="border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.cumulativeTitle')}</span>
|
||||
<span className="text-[11px] text-slate-500 font-mono">{t('chart.total', { value: fmtTokens(max) })}</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-36" role="img" aria-label={ariaLabel}>
|
||||
{/* A single bucket can't form a line — draw the point so it's visible. */}
|
||||
{n === 1 ? (
|
||||
<circle cx={coords[0].x} cy={coords[0].y} r={3} fill="var(--accent, #6366f1)" vectorEffect="non-scaling-stroke" />
|
||||
) : (
|
||||
<polyline points={points} fill="none" stroke="var(--accent, #6366f1)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
</svg>
|
||||
<div className="flex justify-between mt-1 text-[10px] text-slate-400 font-mono">
|
||||
<span>{series[0]?.bucket}</span>
|
||||
<span>{series[series.length - 1]?.bucket}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ t }: { t: TFn }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: GW }} />{t('chart.legendGateway')}</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: DR }} />{t('chart.legendDirect')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: string } & UsageCounters>; t: TFn }) {
|
||||
// Localize the sentinels; real users show their resolved name with the id as
|
||||
// a secondary line.
|
||||
const label = (r: { userId: string; displayName: string }): { primary: string; secondary?: string } => {
|
||||
if (r.userId === 'local') return { primary: t('byUser.local') };
|
||||
if (r.userId === 'system') return { primary: t('byUser.system') };
|
||||
return { primary: r.displayName, secondary: r.displayName === r.userId ? undefined : r.userId };
|
||||
};
|
||||
return (
|
||||
<div className="border border-hairline rounded-lg p-4">
|
||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">{t('byUser.title')}</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-[11px] text-slate-400 text-left">
|
||||
<th className="font-medium pb-1">{t('byUser.user')}</th>
|
||||
<th className="font-medium pb-1 text-right">{t('byUser.input')}</th>
|
||||
<th className="font-medium pb-1 text-right">{t('byUser.output')}</th>
|
||||
<th className="font-medium pb-1 text-right">{t('byUser.requests')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const l = label(r);
|
||||
return (
|
||||
<tr key={r.userId} className="border-t border-hairline">
|
||||
<td className="py-1.5 text-slate-600 dark:text-slate-300">
|
||||
<span>{l.primary}</span>
|
||||
{l.secondary && <span className="ml-1.5 text-[11px] text-slate-400 font-mono">{l.secondary}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono">{fmtTokens(r.tokensIn)}</td>
|
||||
<td className="py-1.5 text-right font-mono">{fmtTokens(r.tokensOut)}</td>
|
||||
<td className="py-1.5 text-right font-mono">{r.requests.toLocaleString()}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user