This commit is contained in:
+17
-9
@@ -21,7 +21,7 @@ import { useBackdropClose } from './lib/useBackdropClose';
|
||||
import { TopBar } from './components/layout/TopBar';
|
||||
import { NavDrawer } from './components/layout/NavDrawer';
|
||||
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
|
||||
import { visibleNavItemsFor, useCompactNav } from './components/layout/TopBar';
|
||||
import { visibleNavItemsFor } from './components/layout/TopBar';
|
||||
import { ResizeHandle } from './components/layout/ResizeHandle';
|
||||
import { TaskListPanel } from './components/list/TaskListPanel';
|
||||
import { ChatPane } from './components/chat/ChatPane';
|
||||
@@ -40,6 +40,7 @@ import { UsersPage } from './pages/UsersPage';
|
||||
import { AdminCaptchaPage } from './pages/AdminCaptchaPage';
|
||||
import { SharedView } from './pages/SharedView';
|
||||
import { UserFolderTab } from './components/userfolder/UserFolderTab';
|
||||
import { UsagePage } from './components/usage/UsagePage';
|
||||
import { HelpPage } from './pages/HelpPage';
|
||||
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
|
||||
import type { ConsoleStatus } from './lib/ssh-console-types';
|
||||
@@ -163,7 +164,9 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false));
|
||||
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
const compactMode = useCompactNav(isAdmin, authEnabled);
|
||||
// compactMode is measured by TopBar (actual fit) and reported up here so the
|
||||
// nav drawer / edge-swipe stay in sync with whether the hamburger is shown.
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
|
||||
const openNavDrawer = () => {
|
||||
@@ -355,10 +358,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
sortMode: sort,
|
||||
searchQuery: search,
|
||||
activeTaskId: localTaskId,
|
||||
// Owner scope (自分/すべて). Only active when auth is on — in no-auth mode
|
||||
// Owner scope (自分/他のユーザ). Only active when auth is on — in no-auth mode
|
||||
// every task is owned by 'local' and the toggle would be meaningless.
|
||||
scope: urlState.scope,
|
||||
onScopeChange: (scope: 'mine' | 'all') => setUrlState(prev => ({ ...prev, scope })),
|
||||
onScopeChange: (scope: 'mine' | 'others') => setUrlState(prev => ({ ...prev, scope })),
|
||||
currentUserId: user?.id ?? null,
|
||||
scopeEnabled: authEnabled && !!user,
|
||||
onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })),
|
||||
@@ -451,6 +454,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
hamburgerButtonRef={hamburgerRef}
|
||||
navDrawerOpen={navDrawerOpen}
|
||||
onOpenCommandK={() => setCmdkOpen(true)}
|
||||
onCompactChange={setCompactMode}
|
||||
/>
|
||||
|
||||
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
|
||||
@@ -471,6 +475,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
|
||||
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
|
||||
{page === 'userfolder' && <div className="flex-1 min-h-0 overflow-hidden"><UserFolderTab showToast={showToast} /></div>}
|
||||
{page === 'usage' && <div className="flex-1 min-h-0 overflow-hidden flex flex-col"><UsagePage /></div>}
|
||||
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
|
||||
|
||||
{page === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden">
|
||||
@@ -563,8 +568,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* タブレット: 2カラム (sm 〜 lg) */}
|
||||
<div className="hidden sm:grid lg:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
|
||||
{/* タブレット: 2カラム (sm 〜 xl)。3列デスクトップは横幅が足りないと詳細列が
|
||||
狭まりタブが折り返すため、切替を xl(1280px) まで上げて中間帯はこの
|
||||
2列+詳細オーバーレイで運用する。 */}
|
||||
<div className="hidden sm:grid xl:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
|
||||
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
|
||||
<TaskListWithSidePanel
|
||||
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
|
||||
@@ -583,9 +590,9 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* デスクトップ: >= lg (1024px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
|
||||
{/* デスクトップ: >= xl (1280px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
|
||||
<div
|
||||
className="hidden lg:grid gap-2 p-2 h-full"
|
||||
className="hidden xl:grid gap-2 p-2 h-full"
|
||||
data-focused-grid={isFocused ? '1' : undefined}
|
||||
style={gridStyle}
|
||||
>
|
||||
@@ -642,7 +649,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
|
||||
{/* Tablet: detail overlay */}
|
||||
{tabletDetailOpen && panelOpen && (
|
||||
<div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
|
||||
<div className="hidden sm:block xl:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
|
||||
<div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
|
||||
{localTaskId && (
|
||||
<LocalDetailPanel
|
||||
@@ -675,6 +682,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
section={previewState.section}
|
||||
filePath={previewState.filePath}
|
||||
editable={previewState.editable}
|
||||
trustedHtmlUrl={user || !authEnabled ? previewState.trustedHtmlUrl : undefined}
|
||||
/>
|
||||
)}
|
||||
{branding.footerText && (
|
||||
|
||||
+61
-1
@@ -99,9 +99,13 @@ export interface SubtaskActivity {
|
||||
activityLog: string;
|
||||
}
|
||||
|
||||
export type TitleSource = 'auto' | 'agent' | 'user';
|
||||
|
||||
export interface LocalTask {
|
||||
id: number;
|
||||
title: string;
|
||||
/** Provenance of the title: 'auto' (creation fallback), 'agent' (derived from goal), 'user' (manual edit). */
|
||||
titleSource?: TitleSource;
|
||||
body: string;
|
||||
pieceName: string;
|
||||
profile: string;
|
||||
@@ -255,7 +259,7 @@ export async function postLocalTaskComment(taskId: number, body: string, author:
|
||||
|
||||
export async function updateLocalTask(
|
||||
taskId: number,
|
||||
updates: { visibility?: Visibility; visibilityScopeOrgId?: string | null },
|
||||
updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null },
|
||||
): Promise<LocalTask> {
|
||||
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -267,6 +271,14 @@ export async function updateLocalTask(
|
||||
return data.task;
|
||||
}
|
||||
|
||||
/** Trigger on-demand AI title regeneration. Owner/admin only. Returns the new title. */
|
||||
export async function regenerateTaskTitle(taskId: number): Promise<string> {
|
||||
const res = await fetch(`${BASE}/local/tasks/${taskId}/regenerate-title`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? 'Failed to regenerate title');
|
||||
return data.title as string;
|
||||
}
|
||||
|
||||
export async function continueTaskWithPiece(
|
||||
taskId: number,
|
||||
body: { piece: string; instruction: string },
|
||||
@@ -319,6 +331,11 @@ export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input
|
||||
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
|
||||
const params = new URLSearchParams({ section, path, trusted: '1' });
|
||||
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, {
|
||||
method: 'PUT',
|
||||
@@ -1266,3 +1283,46 @@ export async function postTestNotification(): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' });
|
||||
return notificationsJsonOrThrow(res, 'failed to send test notification');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LLM usage dashboard (per-user, gateway + direct).
|
||||
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||
export interface UsageCounters {
|
||||
tokensIn: number;
|
||||
tokensOut: number;
|
||||
requests: number;
|
||||
}
|
||||
export interface UsageBucket {
|
||||
bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM'
|
||||
gateway: UsageCounters;
|
||||
direct: UsageCounters;
|
||||
}
|
||||
export interface UsageByUser extends UsageCounters {
|
||||
userId: string;
|
||||
/** Resolved display name (real users); 'local' / 'system' for sentinels. */
|
||||
displayName: string;
|
||||
}
|
||||
export interface UsageDailyResponse {
|
||||
from: string;
|
||||
to: string;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
scope: 'all' | 'self';
|
||||
series: UsageBucket[];
|
||||
totals: { gateway: UsageCounters; direct: UsageCounters };
|
||||
byUser?: UsageByUser[]; // admin / local mode only
|
||||
}
|
||||
|
||||
export async function getUsageDaily(params: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
granularity?: 'day' | 'week' | 'month';
|
||||
}): Promise<UsageDailyResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.from) qs.set('from', params.from);
|
||||
if (params.to) qs.set('to', params.to);
|
||||
if (params.granularity) qs.set('granularity', params.granularity);
|
||||
const q = qs.toString();
|
||||
const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`);
|
||||
if (!res.ok) throw new Error(`Failed to load usage (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
fetchLocalFileContent,
|
||||
fetchSubtaskFileContent,
|
||||
getLocalFileRawUrl,
|
||||
getTrustedLocalHtmlUrl,
|
||||
subtaskFileRawUrl,
|
||||
} from '../api';
|
||||
import { isImagePreviewable, isPdfPreviewable, isTextPreviewable, isHtmlPreviewable } from '../lib/utils';
|
||||
@@ -17,6 +18,7 @@ export interface PreviewState {
|
||||
section?: string;
|
||||
filePath?: string;
|
||||
editable?: boolean;
|
||||
trustedHtmlUrl?: string;
|
||||
}
|
||||
|
||||
export function useFilePreview(onError: (msg: string) => void) {
|
||||
@@ -32,7 +34,11 @@ export function useFilePreview(onError: (msg: string) => void) {
|
||||
try {
|
||||
const canEdit = section === 'output' && isTextPreviewable(name);
|
||||
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
|
||||
setPreviewState({ name, content: '', imageSrc: getLocalFileRawUrl(taskId, section, filePath), taskId, section, filePath, editable: false });
|
||||
const imageSrc = getLocalFileRawUrl(taskId, section, filePath);
|
||||
const trustedHtmlUrl = isHtmlPreviewable(name)
|
||||
? getTrustedLocalHtmlUrl(taskId, section, filePath)
|
||||
: undefined;
|
||||
setPreviewState({ name, content: '', imageSrc, trustedHtmlUrl, taskId, section, filePath, editable: false });
|
||||
return;
|
||||
}
|
||||
const content = await fetchLocalFileContent(taskId, section, filePath);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"focus": { "toStandard": "Back to standard view", "toFocused": "Focused mode (TASK column to a thin rail / split chat and workspace)", "toFocusedShort": "Switch to focused mode" },
|
||||
"feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit" },
|
||||
"title": { "edit": "Edit title", "regenerate": "Regenerate title with AI", "save": "Save", "saving": "Saving...", "cancel": "Cancel" },
|
||||
"mission": {
|
||||
"pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save",
|
||||
"emptyHelp": "No Mission Brief yet. The agent writes here automatically as needed, but pinning the goal / progress / remaining work manually keeps the essence in view even through long conversations.",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"preparing": "Preparing...",
|
||||
"button": "PDF / Print"
|
||||
},
|
||||
"trustedHtml": { "button": "Open as trusted", "tooltip": "Open in a new tab with scripts allowed" },
|
||||
"cancel": "Cancel",
|
||||
"saving": "Saving...",
|
||||
"save": "Save",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"settings": "Settings",
|
||||
"users": "Users",
|
||||
"help": "Help",
|
||||
"userfolder": "User Folder"
|
||||
"userfolder": "User Folder",
|
||||
"usage": "Usage"
|
||||
},
|
||||
"commandPalette": {
|
||||
"open": "Open command palette",
|
||||
|
||||
@@ -41,6 +41,6 @@
|
||||
"scope": {
|
||||
"aria": "Task display scope",
|
||||
"mine": "Mine",
|
||||
"all": "All"
|
||||
"others": "Others"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,5 +779,18 @@
|
||||
},
|
||||
"settingsPage": {
|
||||
"sectionList": "Section list"
|
||||
},
|
||||
"serverTls": {
|
||||
"title": "HTTPS / TLS",
|
||||
"enabled": "Serve over HTTPS",
|
||||
"enabledHelp": "Terminate TLS in the app. Self-signed by default — browsers (and the noVNC / SSH console over wss) will warn until you install a real certificate. Requires a restart to apply.",
|
||||
"certFile": "Certificate file (PEM)",
|
||||
"keyFile": "Private key file (PEM)",
|
||||
"certHelp": "Leave both empty to use an auto-generated self-signed certificate.",
|
||||
"httpRedirect": "Redirect HTTP to HTTPS",
|
||||
"httpRedirectPort": "HTTP redirect port",
|
||||
"redirectHost": "Redirect host (optional)",
|
||||
"selfSignedHosts": "Additional certificate hostnames",
|
||||
"restartBanner": "Changes to HTTPS settings require a server restart to take effect."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"title": "LLM Usage",
|
||||
"subtitle": "Combined token usage across AAO Gateway and direct calls, aggregated per UTC day. This is a separate view from the gateway per-key billing panel.",
|
||||
"range": {
|
||||
"last7": "Last 7 days",
|
||||
"last30": "Last 30 days",
|
||||
"last90": "Last 90 days",
|
||||
"ytd": "Year to date",
|
||||
"custom": "Custom",
|
||||
"invalid": "Start date must be on or before the end date."
|
||||
},
|
||||
"granularity": {
|
||||
"label": "Granularity",
|
||||
"day": "Day",
|
||||
"week": "Week",
|
||||
"month": "Month"
|
||||
},
|
||||
"totals": {
|
||||
"input": "Input tokens",
|
||||
"output": "Output tokens",
|
||||
"requests": "Requests",
|
||||
"gateway": "Gateway",
|
||||
"direct": "Direct",
|
||||
"combined": "Combined"
|
||||
},
|
||||
"chart": {
|
||||
"tokensTitle": "Tokens by period (gateway vs direct)",
|
||||
"cumulativeTitle": "Cumulative tokens",
|
||||
"legendGateway": "Gateway",
|
||||
"legendDirect": "Direct",
|
||||
"total": "Total {{value}}",
|
||||
"barsAria": "{{title}}: {{total}} tokens total across {{count}} periods",
|
||||
"lineAria": "Cumulative tokens over time, reaching {{total}} total"
|
||||
},
|
||||
"byUser": {
|
||||
"title": "By user",
|
||||
"user": "User",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"requests": "Requests",
|
||||
"local": "Local",
|
||||
"system": "System"
|
||||
},
|
||||
"empty": "No usage recorded in this range yet. Aggregation starts from the day this feature went live; past usage cannot be backfilled.",
|
||||
"loading": "Loading usage…",
|
||||
"error": "Failed to load usage."
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"focus": { "toStandard": "標準表示に戻る", "toFocused": "集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)", "toFocusedShort": "集中モードに切替" },
|
||||
"feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信" },
|
||||
"title": { "edit": "タイトルを編集", "regenerate": "AIでタイトルを再生成", "save": "保存", "saving": "保存中...", "cancel": "キャンセル" },
|
||||
"mission": {
|
||||
"pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存",
|
||||
"emptyHelp": "まだ Mission Brief は設定されていません。エージェントが必要に応じて自動で書き込みますが、手動で目標 / 進捗 / 残タスクをここに固定しておくことで、長い会話の途中でも本質を見失わないように誘導できます。",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"preparing": "準備中...",
|
||||
"button": "PDF / 印刷"
|
||||
},
|
||||
"trustedHtml": { "button": "信頼済みとして開く", "tooltip": "スクリプトを許可して別タブで開く" },
|
||||
"cancel": "キャンセル",
|
||||
"saving": "保存中...",
|
||||
"save": "保存",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"settings": "設定",
|
||||
"users": "ユーザー",
|
||||
"help": "ヘルプ",
|
||||
"userfolder": "ユーザーフォルダ"
|
||||
"userfolder": "ユーザーフォルダ",
|
||||
"usage": "使用量"
|
||||
},
|
||||
"commandPalette": {
|
||||
"open": "コマンドパレットを開く",
|
||||
|
||||
@@ -41,6 +41,6 @@
|
||||
"scope": {
|
||||
"aria": "タスクの表示範囲",
|
||||
"mine": "自分",
|
||||
"all": "すべて"
|
||||
"others": "他のユーザ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,5 +779,18 @@
|
||||
},
|
||||
"settingsPage": {
|
||||
"sectionList": "セクション一覧"
|
||||
},
|
||||
"serverTls": {
|
||||
"title": "HTTPS / TLS",
|
||||
"enabled": "HTTPS で配信",
|
||||
"enabledHelp": "アプリ内で TLS を終端します。デフォルトは自己署名証明書のため、正式な証明書を導入するまでブラウザ(および wss 経由の noVNC・SSH コンソール)に警告が表示されます。反映にはサーバーの再起動が必要です。",
|
||||
"certFile": "証明書ファイル(PEM)",
|
||||
"keyFile": "秘密鍵ファイル(PEM)",
|
||||
"certHelp": "両方を空欄にすると、自動生成の自己署名証明書を使用します。",
|
||||
"httpRedirect": "HTTP を HTTPS へリダイレクト",
|
||||
"httpRedirectPort": "HTTP リダイレクトポート",
|
||||
"redirectHost": "リダイレクト先ホスト(省略可)",
|
||||
"selfSignedHosts": "証明書の追加ホスト名",
|
||||
"restartBanner": "HTTPS 設定の変更を反映するには、サーバーの再起動が必要です。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"title": "LLM 使用量",
|
||||
"subtitle": "AAO Gateway 経由と Direct を合算したトークン使用量を UTC 日次で集計します。ゲートウェイのキー別課金パネルとは別集計です。",
|
||||
"range": {
|
||||
"last7": "直近7日",
|
||||
"last30": "直近30日",
|
||||
"last90": "直近90日",
|
||||
"ytd": "年初来",
|
||||
"custom": "カスタム",
|
||||
"invalid": "開始日は終了日以前にしてください。"
|
||||
},
|
||||
"granularity": {
|
||||
"label": "粒度",
|
||||
"day": "日",
|
||||
"week": "週",
|
||||
"month": "月"
|
||||
},
|
||||
"totals": {
|
||||
"input": "入力トークン",
|
||||
"output": "出力トークン",
|
||||
"requests": "リクエスト",
|
||||
"gateway": "Gateway",
|
||||
"direct": "Direct",
|
||||
"combined": "合計"
|
||||
},
|
||||
"chart": {
|
||||
"tokensTitle": "期間別トークン(Gateway / Direct)",
|
||||
"cumulativeTitle": "累積トークン",
|
||||
"legendGateway": "Gateway",
|
||||
"legendDirect": "Direct",
|
||||
"total": "合計 {{value}}",
|
||||
"barsAria": "{{title}}:合計 {{total}} トークン、{{count}} 期間",
|
||||
"lineAria": "累積トークンの推移、合計 {{total}} に到達"
|
||||
},
|
||||
"byUser": {
|
||||
"title": "ユーザー別",
|
||||
"user": "ユーザー",
|
||||
"input": "入力",
|
||||
"output": "出力",
|
||||
"requests": "リクエスト",
|
||||
"local": "ローカル",
|
||||
"system": "システム"
|
||||
},
|
||||
"empty": "この期間の使用量はまだありません。集計はこの機能の稼働開始日から始まり、過去分は遡って集計できません。",
|
||||
"loading": "使用量を読み込み中…",
|
||||
"error": "使用量の読み込みに失敗しました。"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cronToFormState } from './cronForm';
|
||||
|
||||
/**
|
||||
* The backend's convertToCron (src/scheduler.ts) — mirrored here so the test
|
||||
* asserts a true round-trip: editing a saved schedule and saving it back must
|
||||
* not change the cron expression.
|
||||
*/
|
||||
function convertToCron(f: ReturnType<typeof cronToFormState>): string {
|
||||
const m = f.minute ?? 0;
|
||||
const h = f.hour ?? 0;
|
||||
switch (f.scheduleType) {
|
||||
case 'daily': return `${m} ${h} * * *`;
|
||||
case 'weekly': return `${m} ${h} * * ${f.dayOfWeek ?? 0}`;
|
||||
case 'monthly': return `${m} ${h} ${f.dayOfMonth ?? 1} * *`;
|
||||
case 'cron': return f.cronExpression;
|
||||
case 'once': return 'once';
|
||||
}
|
||||
}
|
||||
|
||||
describe('cronToFormState', () => {
|
||||
it('classifies simple presets (daily/weekly/monthly)', () => {
|
||||
expect(cronToFormState('0 9 * * *')).toMatchObject({ scheduleType: 'daily', hour: 9, minute: 0 });
|
||||
expect(cronToFormState('30 14 * * *')).toMatchObject({ scheduleType: 'daily', hour: 14, minute: 30 });
|
||||
expect(cronToFormState('0 9 * * 3')).toMatchObject({ scheduleType: 'weekly', hour: 9, minute: 0, dayOfWeek: 3 });
|
||||
expect(cronToFormState('0 9 15 * *')).toMatchObject({ scheduleType: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 });
|
||||
});
|
||||
|
||||
it("keeps custom expressions as 'cron' and preserves the raw expression", () => {
|
||||
for (const expr of [
|
||||
'0 9 * * 1-5', '*/30 * * * *', '0 */2 * * *', '0 9,17 * * *', '15 9 1,15 * *', '0 9 1 6 *',
|
||||
'0 9 * * 7', // Sunday-as-7 is out of the editor's 0-6 select range
|
||||
'0 9 * * *', // double space → 6 tokens, not a clean preset
|
||||
]) {
|
||||
const f = cronToFormState(expr);
|
||||
expect(f.scheduleType).toBe('cron');
|
||||
expect(f.cronExpression).toBe(expr);
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies Sunday-as-0 weekly', () => {
|
||||
expect(cronToFormState('0 9 * * 0')).toMatchObject({ scheduleType: 'weekly', dayOfWeek: 0 });
|
||||
});
|
||||
|
||||
it('never produces NaN fields for custom expressions', () => {
|
||||
const f = cronToFormState('0 9 * * 1-5');
|
||||
expect(Number.isNaN(f.hour)).toBe(false);
|
||||
expect(Number.isNaN(f.minute)).toBe(false);
|
||||
expect(Number.isNaN(f.dayOfWeek)).toBe(false);
|
||||
expect(Number.isNaN(f.dayOfMonth)).toBe(false);
|
||||
});
|
||||
|
||||
it("maps 'once' to the once type", () => {
|
||||
expect(cronToFormState('once')).toMatchObject({ scheduleType: 'once' });
|
||||
});
|
||||
|
||||
it('treats a malformed (non 5-field) string as a raw cron expression', () => {
|
||||
expect(cronToFormState('garbage')).toMatchObject({ scheduleType: 'cron', cronExpression: 'garbage' });
|
||||
});
|
||||
|
||||
it('round-trips losslessly through the backend convertToCron', () => {
|
||||
const cases = [
|
||||
'0 9 * * *', '30 14 * * *', '0 9 * * 3', '0 9 15 * *',
|
||||
'0 9 * * 1-5', '*/30 * * * *', '0 */2 * * *', '0 9,17 * * *',
|
||||
'15 9 1,15 * *', '0 9 1 6 *', 'once',
|
||||
];
|
||||
for (const expr of cases) {
|
||||
expect(convertToCron(cronToFormState(expr))).toBe(expr);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* cronForm.ts — 保存済み cron 文字列を、スケジュール編集フォームのフィールドに
|
||||
* 復元する純関数。
|
||||
*
|
||||
* スケジュールは DB 上ではすべて cron 文字列なので、編集時にどの「種別」
|
||||
* (daily / weekly / monthly / cron / once) として表示するかをここで判定する。
|
||||
*
|
||||
* 重要: daily/weekly/monthly のプリセットへ変換するのは、対象フィールドが
|
||||
* **単純な整数** (例: `0 9 * * *`) のときだけ。範囲 (`1-5`) / ステップ (`*/30`) /
|
||||
* リスト (`9,17`) / 非 `*` の月を含む式は、プリセットでは表現できない。これらを
|
||||
* 無理にプリセット化すると `Number()` が NaN を返してフィールドが壊れ、かつ
|
||||
* 元の cron 式が失われる。そうした式は `cron` 種別のまま生の式を保持する。
|
||||
*/
|
||||
|
||||
export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'cron' | 'once';
|
||||
|
||||
export interface ScheduleFields {
|
||||
scheduleType: ScheduleType;
|
||||
hour: number;
|
||||
minute: number;
|
||||
dayOfWeek: number;
|
||||
dayOfMonth: number;
|
||||
cronExpression: string;
|
||||
}
|
||||
|
||||
const isInt = (s: string): boolean => /^\d+$/.test(s);
|
||||
const inRange = (s: string, lo: number, hi: number): boolean => {
|
||||
if (!isInt(s)) return false;
|
||||
const n = Number(s);
|
||||
return n >= lo && n <= hi;
|
||||
};
|
||||
|
||||
export function cronToFormState(cron: string): ScheduleFields {
|
||||
// 任意の cron 文字列をそのまま保持するフォールバック (生の式を捨てない)。
|
||||
const asCron: ScheduleFields = {
|
||||
scheduleType: 'cron',
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
dayOfWeek: 1,
|
||||
dayOfMonth: 1,
|
||||
cronExpression: cron,
|
||||
};
|
||||
|
||||
if (cron === 'once') {
|
||||
return { scheduleType: 'once', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
|
||||
}
|
||||
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length !== 5) return asCron;
|
||||
|
||||
const [min, hour, dom, mon, dow] = parts;
|
||||
// プリセットは「分・時が整数」かつ「月は毎月 (*)」が前提。満たさなければ cron 扱い。
|
||||
if (mon !== '*' || !isInt(min) || !isInt(hour)) return asCron;
|
||||
|
||||
const h = Number(hour);
|
||||
const m = Number(min);
|
||||
|
||||
if (dom === '*' && dow === '*') {
|
||||
return { scheduleType: 'daily', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
|
||||
}
|
||||
// weekly/monthly only when the day value is within the editor's select range
|
||||
// (dow 0-6, dom 1-31). Out-of-range forms like `* * * * 7` (Sunday-as-7) stay
|
||||
// as raw cron so re-saving never rewrites them to a different value.
|
||||
if (dom === '*' && inRange(dow, 0, 6)) {
|
||||
return { scheduleType: 'weekly', hour: h, minute: m, dayOfWeek: Number(dow), dayOfMonth: 1, cronExpression: '' };
|
||||
}
|
||||
if (inRange(dom, 1, 31) && dow === '*') {
|
||||
return { scheduleType: 'monthly', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: Number(dom), cronExpression: '' };
|
||||
}
|
||||
|
||||
// dom と dow が両方指定など、プリセットに当てはまらない組み合わせは cron 扱い。
|
||||
return asCron;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { splitFileName, fileCategory, categoryColorClass } from './fileType';
|
||||
|
||||
describe('splitFileName', () => {
|
||||
it('splits a normal name into stem + ext (ext keeps the dot)', () => {
|
||||
expect(splitFileName('report.xlsx')).toEqual({ stem: 'report', ext: '.xlsx' });
|
||||
});
|
||||
it('uses the LAST dot for multi-dot names', () => {
|
||||
expect(splitFileName('archive.tar.gz')).toEqual({ stem: 'archive.tar', ext: '.gz' });
|
||||
});
|
||||
it('treats a leading-dot dotfile as having no extension', () => {
|
||||
expect(splitFileName('.gitignore')).toEqual({ stem: '.gitignore', ext: '' });
|
||||
});
|
||||
it('returns empty ext when there is no dot', () => {
|
||||
expect(splitFileName('README')).toEqual({ stem: 'README', ext: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileCategory', () => {
|
||||
it('maps known extensions (case-insensitive)', () => {
|
||||
expect(fileCategory('a.PNG')).toBe('image');
|
||||
expect(fileCategory('a.pdf')).toBe('pdf');
|
||||
expect(fileCategory('a.xlsx')).toBe('spreadsheet');
|
||||
expect(fileCategory('a.md')).toBe('document');
|
||||
expect(fileCategory('a.pptx')).toBe('presentation');
|
||||
expect(fileCategory('main.ts')).toBe('code');
|
||||
expect(fileCategory('a.zip')).toBe('archive');
|
||||
expect(fileCategory('a.mp3')).toBe('audio');
|
||||
expect(fileCategory('a.mp4')).toBe('video');
|
||||
});
|
||||
it('falls back to other for unknown or missing extensions', () => {
|
||||
expect(fileCategory('a.unknownext')).toBe('other');
|
||||
expect(fileCategory('README')).toBe('other');
|
||||
expect(fileCategory('.gitignore')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('categoryColorClass', () => {
|
||||
it('returns a literal tailwind text-color class for every category', () => {
|
||||
for (const cat of ['image', 'pdf', 'spreadsheet', 'document', 'presentation', 'code', 'archive', 'audio', 'video', 'other'] as const) {
|
||||
expect(categoryColorClass(cat)).toMatch(/^text-\w+-\d{3}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* fileType.ts — ファイル名から拡張子・種別カテゴリ・表示色を導く純関数。
|
||||
*
|
||||
* ファイルタブで (a) 種別ごとの色付きアイコン表示、(b) 長い名前でも拡張子を
|
||||
* 常に見せる中間省略表示、の両方に使う。
|
||||
*/
|
||||
|
||||
export type FileCategory =
|
||||
| 'image'
|
||||
| 'pdf'
|
||||
| 'spreadsheet'
|
||||
| 'document'
|
||||
| 'presentation'
|
||||
| 'code'
|
||||
| 'archive'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'other';
|
||||
|
||||
/**
|
||||
* ファイル名を「本体」と「拡張子」に分割する。拡張子には先頭の `.` を含む。
|
||||
* - `report.xlsx` → { stem: 'report', ext: '.xlsx' }
|
||||
* - `archive.tar.gz` → { stem: 'archive.tar', ext: '.gz' }(最後のドット基準)
|
||||
* - `.gitignore`(ドットファイル)→ { stem: '.gitignore', ext: '' }
|
||||
* - `README`(拡張子なし)→ { stem: 'README', ext: '' }
|
||||
*/
|
||||
export function splitFileName(name: string): { stem: string; ext: string } {
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot <= 0) return { stem: name, ext: '' }; // 先頭ドット(ドットファイル) or ドット無し
|
||||
return { stem: name.slice(0, dot), ext: name.slice(dot) };
|
||||
}
|
||||
|
||||
// 拡張子(小文字・ドット無し) → カテゴリ
|
||||
const EXT_CATEGORY: Record<string, FileCategory> = {};
|
||||
const register = (cat: FileCategory, exts: string[]) => {
|
||||
for (const e of exts) EXT_CATEGORY[e] = cat;
|
||||
};
|
||||
register('image', ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'tif', 'tiff', 'heic']);
|
||||
register('pdf', ['pdf']);
|
||||
register('spreadsheet', ['xlsx', 'xls', 'xlsm', 'csv', 'tsv', 'ods']);
|
||||
register('document', ['doc', 'docx', 'odt', 'rtf', 'txt', 'md', 'markdown', 'log']);
|
||||
register('presentation', ['ppt', 'pptx', 'odp', 'key']);
|
||||
register('code', [
|
||||
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'kt',
|
||||
'c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'php', 'swift', 'sh', 'bash', 'zsh', 'sql',
|
||||
'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'htm', 'css', 'scss', 'less', 'ipynb',
|
||||
]);
|
||||
register('archive', ['zip', 'tar', 'gz', 'tgz', 'rar', '7z', 'bz2', 'xz', 'zst']);
|
||||
register('audio', ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus']);
|
||||
register('video', ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']);
|
||||
|
||||
export function fileCategory(name: string): FileCategory {
|
||||
const { ext } = splitFileName(name);
|
||||
if (!ext) return 'other';
|
||||
return EXT_CATEGORY[ext.slice(1).toLowerCase()] ?? 'other';
|
||||
}
|
||||
|
||||
// Tailwind の content スキャンに拾わせるため、色クラスはリテラル文字列で持つ
|
||||
// (動的に `text-${x}-500` を組み立てると JIT が purge する)。
|
||||
const CATEGORY_COLOR: Record<FileCategory, string> = {
|
||||
image: 'text-cyan-500',
|
||||
pdf: 'text-red-500',
|
||||
spreadsheet: 'text-green-600',
|
||||
document: 'text-blue-500',
|
||||
presentation: 'text-orange-500',
|
||||
code: 'text-indigo-500',
|
||||
archive: 'text-amber-500',
|
||||
audio: 'text-purple-500',
|
||||
video: 'text-pink-500',
|
||||
other: 'text-slate-400',
|
||||
};
|
||||
|
||||
export function categoryColorClass(cat: FileCategory): string {
|
||||
return CATEGORY_COLOR[cat];
|
||||
}
|
||||
@@ -14,12 +14,20 @@ describe('filterTasksByScope', () => {
|
||||
expect(filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("scope='all' returns everything", () => {
|
||||
expect(filterTasksByScope(tasks, 'all', 'alice')).toHaveLength(5);
|
||||
it("scope='others' keeps everyone else's tasks (incl. legacy null owners)", () => {
|
||||
expect(filterTasksByScope(tasks, 'others', 'alice').map(t => t.id)).toEqual([2, 4, 5]);
|
||||
});
|
||||
|
||||
it('no current user (auth disabled) returns everything even for mine', () => {
|
||||
it('mine + others partition the full list with no overlap', () => {
|
||||
const mine = filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id);
|
||||
const others = filterTasksByScope(tasks, 'others', 'alice').map(t => t.id);
|
||||
expect([...mine, ...others].sort()).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(mine.filter(id => others.includes(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it('no current user (auth disabled) returns everything for either scope', () => {
|
||||
expect(filterTasksByScope(tasks, 'mine', null)).toHaveLength(5);
|
||||
expect(filterTasksByScope(tasks, 'others', null)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('legacy null/undefined owners never match mine', () => {
|
||||
|
||||
+10
-7
@@ -2,20 +2,23 @@
|
||||
* taskScope.ts — タスク一覧の所有者スコープフィルタ。
|
||||
*
|
||||
* visibility (public / org) のせいで他ユーザーのタスクが一覧に混ざり
|
||||
* 「自分のタスクを見失う」問題への対策。'mine' は ownerId が自分のものだけを
|
||||
* 残す。認証無効時 (currentUserId が無い時) はフィルタ自体が無意味なので
|
||||
* 全件を返す。
|
||||
* 「自分のタスクを見失う」問題への対策。重複のない2分割:
|
||||
* 'mine' — ownerId が自分のものだけ
|
||||
* 'others' — それ以外 (他ユーザー + legacy null owner)
|
||||
* 認証無効時 (currentUserId が無い時) はフィルタ自体が無意味なので全件を返す。
|
||||
*/
|
||||
|
||||
export type TaskScope = 'mine' | 'all';
|
||||
export type TaskScope = 'mine' | 'others';
|
||||
|
||||
export const TASK_SCOPES: TaskScope[] = ['mine', 'all'];
|
||||
export const TASK_SCOPES: TaskScope[] = ['mine', 'others'];
|
||||
|
||||
export function filterTasksByScope<T extends { ownerId?: string | null }>(
|
||||
tasks: T[],
|
||||
scope: TaskScope,
|
||||
currentUserId: string | null,
|
||||
): T[] {
|
||||
if (scope !== 'mine' || !currentUserId) return tasks;
|
||||
return tasks.filter(t => t.ownerId === currentUserId);
|
||||
if (!currentUserId) return tasks;
|
||||
return scope === 'mine'
|
||||
? tasks.filter(t => t.ownerId === currentUserId)
|
||||
: tasks.filter(t => t.ownerId !== currentUserId);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('buildUiUrlStateSearch', () => {
|
||||
status: 'waiting_human',
|
||||
search: 'foo bar',
|
||||
sort: 'status',
|
||||
scope: 'all',
|
||||
scope: 'others',
|
||||
detailTab: 'trace',
|
||||
mobileTab: 'files',
|
||||
taskId: 99,
|
||||
|
||||
@@ -2,7 +2,7 @@ const COLUMNS = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retr
|
||||
const DETAIL_TABS = ['overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
|
||||
const MOBILE_TABS = ['chat', 'overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
|
||||
const SORT_MODES = ['updated', 'status', 'title'] as const;
|
||||
const PAGES = ['tasks', 'pieces', 'settings', 'schedules', 'users', 'captcha', 'userfolder', 'help'] as const;
|
||||
const PAGES = ['tasks', 'pieces', 'settings', 'schedules', 'users', 'captcha', 'userfolder', 'usage', 'help'] as const;
|
||||
const SETTINGS_SECTIONS = [
|
||||
// User group
|
||||
'preferences',
|
||||
@@ -76,8 +76,9 @@ export interface UiUrlState {
|
||||
status: 'all' | StatusColumn;
|
||||
search: string;
|
||||
sort: SortMode;
|
||||
/** Owner scope for the task list. 'mine' = own tasks only (default when auth is on). */
|
||||
scope: 'mine' | 'all';
|
||||
/** Owner scope for the task list. 'mine' = own tasks only (default when auth is on),
|
||||
* 'others' = everyone else's tasks. */
|
||||
scope: 'mine' | 'others';
|
||||
detailTab: DetailTabId;
|
||||
mobileTab: MobileTabId;
|
||||
taskId: number | null;
|
||||
@@ -131,7 +132,7 @@ export function readUiUrlState(): UiUrlState {
|
||||
: 'all',
|
||||
search: params.get('q') ?? '',
|
||||
sort: sort && SORT_MODES.includes(sort as SortMode) ? sort as SortMode : 'updated',
|
||||
scope: params.get('scope') === 'all' ? 'all' : 'mine',
|
||||
scope: params.get('scope') === 'others' ? 'others' : 'mine',
|
||||
detailTab: detailTab && DETAIL_TABS.includes(detailTab as DetailTabId)
|
||||
? detailTab as DetailTabId
|
||||
: 'overview',
|
||||
@@ -154,7 +155,7 @@ export function buildUiUrlStateSearch(state: UiUrlState): string {
|
||||
if (state.status !== 'all') params.set('status', state.status);
|
||||
if (state.search) params.set('q', state.search);
|
||||
if (state.sort !== 'updated') params.set('sort', state.sort);
|
||||
if (state.scope === 'all') params.set('scope', 'all');
|
||||
if (state.scope === 'others') params.set('scope', 'others');
|
||||
if (state.detailTab !== 'overview') params.set('tab', state.detailTab);
|
||||
if (state.mobileTab !== 'chat') params.set('mobileTab', state.mobileTab);
|
||||
if (state.taskId) params.set('task', String(state.taskId));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StatChip } from '../components/shared/StatChip';
|
||||
import { usePieceList } from '../hooks/usePieces';
|
||||
import { resolvePieceOptions } from '../lib/splitPieces';
|
||||
import { ownerDisplayName } from '../lib/owner';
|
||||
import { cronToFormState } from '../lib/cronForm';
|
||||
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
|
||||
import { useAuthState } from '../App';
|
||||
|
||||
@@ -95,18 +96,10 @@ function parseCronToDisplay(cron: string, t: TFunction): string {
|
||||
return cron;
|
||||
}
|
||||
|
||||
function cronToFormState(cron: string): Pick<ScheduleFormState, 'scheduleType' | 'hour' | 'minute' | 'dayOfWeek' | 'dayOfMonth' | 'cronExpression'> {
|
||||
if (cron === 'once') return { scheduleType: 'once', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length !== 5) return { scheduleType: 'cron', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: cron };
|
||||
const [min, hour, dom, , dow] = parts;
|
||||
const h = Number(hour), m = Number(min);
|
||||
|
||||
if (dom !== '*' && dow === '*') return { scheduleType: 'monthly', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: Number(dom), cronExpression: '' };
|
||||
if (dow !== '*' && dom === '*') return { scheduleType: 'weekly', hour: h, minute: m, dayOfWeek: Number(dow), dayOfMonth: 1, cronExpression: '' };
|
||||
if (dom === '*' && dow === '*') return { scheduleType: 'daily', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
|
||||
return { scheduleType: 'cron', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: cron };
|
||||
}
|
||||
// cronToFormState lives in ../lib/cronForm so it can be unit-tested in isolation.
|
||||
// It only maps to daily/weekly/monthly presets when every field is a simple
|
||||
// integer; custom expressions (ranges/steps/lists) stay as raw 'cron' so editing
|
||||
// a saved schedule never mangles it to NaN or drops the expression.
|
||||
|
||||
function taskToFormState(task: ScheduledTask): ScheduleFormState {
|
||||
const cronState = cronToFormState(task.cronExpression);
|
||||
|
||||
Reference in New Issue
Block a user