Initial commit: Elydora Open Source — self-hosted audit platform for AI agents
Full self-hosted port of the Elydora tamper-evident audit protocol: - Hono API server with PostgreSQL, Redis, MinIO adapters - Ed25519 signed operations, chain hashing, Merkle epoch rollups - PBKDF2 password hashing, HMAC-SHA256 JWT, 5-role RBAC - Next.js management console - Node.js, Python, and Go SDKs - Docker Compose for local deployment - Helm chart for Kubernetes - Comprehensive security hardening and audit fixes applied Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
15547a8c35
commit
e390e68594
207 files changed
+32790
-63
No files matched your search
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import AgentDetailClient from '@/components/AgentDetailClient';
|
||||
|
||||
export default function AgentDetailShell() {
|
||||
const [agentId, setAgentId] = useState('');
|
||||
useEffect(() => {
|
||||
setAgentId(window.location.pathname.split('/')[2] ?? '');
|
||||
}, []);
|
||||
if (!agentId) return null;
|
||||
return <AgentDetailClient agentId={agentId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AgentDetailShell from './client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ agent_id: 'detail' }];
|
||||
}
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
return <AgentDetailShell />;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAgentsList, useAudit, formatRelativeTime } from '@/lib/hooks';
|
||||
import { api } from '@/lib/api';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import SearchInput from '@/components/ui/SearchInput';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import AgentRegistrationForm from '@/components/AgentRegistrationForm';
|
||||
|
||||
interface AgentRow {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
created_at: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Fetches the real total_count for a single agent via the audit API. */
|
||||
function AgentOpCount({ agentId }: { agentId: string }) {
|
||||
const { data, isLoading } = useAudit({ agent_id: agentId });
|
||||
if (isLoading) return <span className="font-mono text-[13px] text-ink-dim">—</span>;
|
||||
return <span className="font-mono text-[13px] text-ink-dim">{data?.total_count ?? 0}</span>;
|
||||
}
|
||||
|
||||
/** Fetches the latest operation timestamp for a single agent via the audit API. */
|
||||
function AgentLatestActivity({ agentId, fallback }: { agentId: string; fallback: number }) {
|
||||
const { data, isLoading } = useAudit({ agent_id: agentId });
|
||||
if (isLoading) return <span className="font-mono text-[12px] text-ink-dim">—</span>;
|
||||
const latest = data?.operations?.[0]?.issued_at ?? fallback;
|
||||
return <span className="font-mono text-[12px] text-ink-dim">{formatRelativeTime(latest)}</span>;
|
||||
}
|
||||
|
||||
function AgentsContent() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AgentRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const { data: agentsData, isLoading: agentsLoading, error: agentsError, mutate } = useAgentsList();
|
||||
|
||||
const agents: AgentRow[] = useMemo(() => {
|
||||
if (!agentsData?.agents) return [];
|
||||
return agentsData.agents.map((agent) => ({
|
||||
agent_id: agent.agent_id,
|
||||
display_name: agent.display_name,
|
||||
status: agent.status,
|
||||
created_at: agent.created_at,
|
||||
}));
|
||||
}, [agentsData]);
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
if (!search.trim()) return agents;
|
||||
const q = search.toLowerCase();
|
||||
return agents.filter(
|
||||
(a) => a.agent_id.toLowerCase().includes(q) || a.display_name.toLowerCase().includes(q),
|
||||
);
|
||||
}, [agents, search]);
|
||||
|
||||
const columns: Column<AgentRow>[] = [
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: t('agents.colAgent'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<div>
|
||||
<span className="font-mono text-[13px] text-ink font-medium">{row.agent_id}</span>
|
||||
{row.display_name && row.display_name !== row.agent_id && (
|
||||
<span className="ml-2 font-mono text-[11px] text-ink-dim">{row.display_name}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('agents.colStatus'),
|
||||
sortable: true,
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span
|
||||
className={`font-mono text-[11px] uppercase tracking-wider ${
|
||||
row.status === 'active'
|
||||
? 'text-ink'
|
||||
: row.status === 'frozen'
|
||||
? 'text-amber-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_count',
|
||||
label: t('agents.colOperations'),
|
||||
width: '120px',
|
||||
render: (row) => <AgentOpCount agentId={row.agent_id} />,
|
||||
},
|
||||
{
|
||||
key: 'latest_activity',
|
||||
label: t('agents.colLatestActivity'),
|
||||
render: (row) => <AgentLatestActivity agentId={row.agent_id} fallback={row.created_at} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<button
|
||||
className="btn-ghost text-[10px] py-1 px-2 text-red-600 hover:text-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(row);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: AgentRow) => {
|
||||
router.push(`/agents/${row.agent_id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleRegisterSuccess = useCallback(() => {
|
||||
setShowRegister(false);
|
||||
mutate();
|
||||
}, [mutate]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.agents.delete(deleteTarget.agent_id);
|
||||
setDeleteTarget(null);
|
||||
mutate();
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : t('agents.failedToDelete'));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [deleteTarget, mutate]);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('agents.title')}
|
||||
subtitle={t('agents.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('agents.title') },
|
||||
]}
|
||||
actions={
|
||||
<button className="btn-brutalist" onClick={() => setShowRegister(true)}>
|
||||
{t('agents.registerAgent')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{agentsError && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('agents.failedToLoad')} {agentsError instanceof Error ? agentsError.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('agents.searchPlaceholder')}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredAgents}
|
||||
keyExtractor={(row) => row.agent_id}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={agentsLoading}
|
||||
emptyMessage={t('agents.emptyMessage')}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={showRegister}
|
||||
onClose={() => setShowRegister(false)}
|
||||
title={t('agents.registerAgent')}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
<AgentRegistrationForm
|
||||
onSuccess={handleRegisterSuccess}
|
||||
onCancel={() => setShowRegister(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={deleteTarget !== null}
|
||||
onClose={() => { setDeleteTarget(null); setDeleteError(null); }}
|
||||
title={t('agents.deleteAgent')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="font-sans text-sm text-ink-dim">
|
||||
{t('agents.deleteConfirmPre')}<strong className="text-ink font-mono text-[13px]">{deleteTarget?.agent_id}</strong>{t('agents.deleteConfirmPost')}
|
||||
</p>
|
||||
{deleteError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => { setDeleteTarget(null); setDeleteError(null); }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="btn-brutalist bg-red-600 text-white border-red-700 hover:bg-red-700"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? t('agents.deleting') : t('agents.deleteAgent')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="fade-in"><div className="border border-border p-6"><div className="space-y-3"><div className="skeleton h-10 w-full max-w-md" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-3/4" /></div></div></div>}>
|
||||
<AgentsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAudit, formatTimestamp, formatRelativeTime } from '@/lib/hooks';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import type { Operation, AuditQueryRequest } from '@elydora/shared';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AuditPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [agentId, setAgentId] = useState('');
|
||||
const [operationType, setOperationType] = useState('');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [limit] = useState(50);
|
||||
|
||||
const queryParams: AuditQueryRequest = {
|
||||
agent_id: agentId || undefined,
|
||||
operation_type: operationType || undefined,
|
||||
start_time: startTime ? new Date(startTime).getTime() : undefined,
|
||||
end_time: endTime ? new Date(endTime).getTime() : undefined,
|
||||
cursor,
|
||||
limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useAudit(queryParams);
|
||||
const operations = data?.operations ?? [];
|
||||
|
||||
const columns: Column<Operation & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'operation_id',
|
||||
label: t('audit.colOperationId'),
|
||||
render: (row) => (
|
||||
<Link
|
||||
href={`/operations/${row.operation_id}`}
|
||||
className="font-mono text-[13px] text-ink hover:underline no-underline"
|
||||
>
|
||||
{row.operation_id.slice(0, 18)}...
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: t('audit.colAgent'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<Link
|
||||
href={`/agents/${row.agent_id}`}
|
||||
className="font-mono text-[13px] text-ink-dim hover:text-ink no-underline transition-colors"
|
||||
>
|
||||
{row.agent_id}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_type',
|
||||
label: t('audit.colType'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.operation_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'issued_at',
|
||||
label: t('audit.colIssuedAt'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim" title={formatTimestamp(row.issued_at)}>
|
||||
{formatRelativeTime(row.issued_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seq_no',
|
||||
label: t('audit.colSeq'),
|
||||
sortable: true,
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'chain_hash',
|
||||
label: t('audit.colChainHash'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{row.chain_hash.slice(0, 12)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setAgentId('');
|
||||
setOperationType('');
|
||||
setStartTime('');
|
||||
setEndTime('');
|
||||
setCursor(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('audit.title')}
|
||||
subtitle={t('audit.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('audit.title') },
|
||||
]}
|
||||
actions={
|
||||
<Link href="/exports" className="btn-ghost inline-block no-underline">
|
||||
{t('audit.exportData')}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('audit.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Query Builder */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="section-label">{t('audit.queryBuilder')}</h3>
|
||||
<button className="btn-ghost text-[10px] py-1 px-3" onClick={handleClearFilters}>
|
||||
{t('audit.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.agentId')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentId}
|
||||
onChange={(e) => {
|
||||
setAgentId(e.target.value);
|
||||
setCursor(undefined);
|
||||
}}
|
||||
placeholder={t('audit.filterByAgent')}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.operationType')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={operationType}
|
||||
onChange={(e) => {
|
||||
setOperationType(e.target.value);
|
||||
setCursor(undefined);
|
||||
}}
|
||||
placeholder={t('audit.filterByType')}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.startTime')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => {
|
||||
setStartTime(e.target.value);
|
||||
setCursor(undefined);
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.endTime')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => {
|
||||
setEndTime(e.target.value);
|
||||
setCursor(undefined);
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex items-center gap-6">
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{t('audit.showing', { shown: operations.length, total: data.total_count })}
|
||||
</span>
|
||||
{data.cursor && (
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{t('audit.moreResults')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results Table */}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={operations as (Operation & Record<string, unknown>)[]}
|
||||
keyExtractor={(row) => row.operation_id as string}
|
||||
onRowClick={(row) => { router.push(`/operations/${row.operation_id}`); }}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('audit.emptyMessage')}
|
||||
pagination={
|
||||
data?.cursor
|
||||
? {
|
||||
cursor: data.cursor,
|
||||
hasMore: true,
|
||||
onLoadMore: () => setCursor(data.cursor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import EpochDetailClient from '@/components/EpochDetailClient';
|
||||
|
||||
export default function EpochDetailShell() {
|
||||
const [epochId, setEpochId] = useState('');
|
||||
useEffect(() => {
|
||||
setEpochId(window.location.pathname.split('/')[2] ?? '');
|
||||
}, []);
|
||||
if (!epochId) return null;
|
||||
return <EpochDetailClient epochId={epochId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import EpochDetailShell from './client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ epoch_id: 'detail' }];
|
||||
}
|
||||
|
||||
export default function EpochDetailPage() {
|
||||
return <EpochDetailShell />;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEpochs, formatTimestamp, truncateHash } from '@/lib/hooks';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
|
||||
interface EpochRow {
|
||||
epoch_id: string;
|
||||
org_id: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
root_hash: string;
|
||||
leaf_count: number;
|
||||
r2_epoch_key: string;
|
||||
created_at: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function EpochsContent() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, error } = useEpochs();
|
||||
const epochs = (data?.epochs ?? []) as EpochRow[];
|
||||
|
||||
const columns: Column<EpochRow>[] = [
|
||||
{
|
||||
key: 'epoch_id',
|
||||
label: t('epochs.colEpochId'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink font-medium">
|
||||
{row.epoch_id.slice(0, 16)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'start_time',
|
||||
label: t('epochs.colTimeRange'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<div className="font-mono text-[11px] text-ink-dim">
|
||||
<div>{formatTimestamp(row.start_time)}</div>
|
||||
<div className="text-[10px]">to {formatTimestamp(row.end_time)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'leaf_count',
|
||||
label: t('epochs.colLeaves'),
|
||||
sortable: true,
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">{row.leaf_count}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'root_hash',
|
||||
label: t('epochs.colRootHash'),
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{truncateHash(row.root_hash, 10)}
|
||||
</span>
|
||||
<CopyButton text={row.root_hash} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('epochs.colCreated'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: EpochRow) => {
|
||||
router.push(`/epochs/${row.epoch_id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('epochs.title')}
|
||||
subtitle={t('epochs.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('epochs.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('epochs.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={epochs}
|
||||
keyExtractor={(row) => row.epoch_id}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('epochs.emptyMessage')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EpochsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="fade-in"><div className="border border-border p-6"><div className="space-y-3"><div className="skeleton h-10 w-full max-w-md" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-3/4" /></div></div></div>}>
|
||||
<EpochsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useExports, formatTimestamp } from '@/lib/hooks';
|
||||
import { api } from '@/lib/api';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import type { Export, CreateExportRequest, ExportStatus } from '@elydora/shared';
|
||||
|
||||
interface ExportRow extends Export {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function ExportsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error, mutate } = useExports();
|
||||
const exports = (data?.exports ?? []) as ExportRow[];
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [agentId, setAgentId] = useState('');
|
||||
const [operationType, setOperationType] = useState('');
|
||||
const [format, setFormat] = useState<'json' | 'pdf'>('json');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!startTime || !endTime) {
|
||||
setCreateError(t('exports.startEndRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const body: CreateExportRequest = {
|
||||
start_time: new Date(startTime).getTime(),
|
||||
end_time: new Date(endTime).getTime(),
|
||||
agent_id: agentId || undefined,
|
||||
operation_type: operationType || undefined,
|
||||
format,
|
||||
};
|
||||
await api.exports.create(body);
|
||||
setShowCreate(false);
|
||||
setStartTime('');
|
||||
setEndTime('');
|
||||
setAgentId('');
|
||||
setOperationType('');
|
||||
mutate();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : t('exports.failedToCreate'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [startTime, endTime, agentId, operationType, format, mutate]);
|
||||
|
||||
const handleDownload = useCallback(async (exportId: string, queryParams: string) => {
|
||||
try {
|
||||
let ext = '';
|
||||
try {
|
||||
const params = JSON.parse(queryParams);
|
||||
ext = params.format === 'pdf' ? '.pdf' : '.json';
|
||||
} catch { ext = ''; }
|
||||
const blob = await api.exports.download(exportId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `export-${exportId}${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Download error is non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
const columns: Column<ExportRow>[] = [
|
||||
{
|
||||
key: 'export_id',
|
||||
label: t('exports.colExportId'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">
|
||||
{row.export_id.slice(0, 16)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('exports.colStatus'),
|
||||
width: '120px',
|
||||
render: (row) => <StatusBadge status={row.status as ExportStatus} size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'query_params',
|
||||
label: t('exports.colFilters'),
|
||||
render: (row) => {
|
||||
let params: Record<string, unknown> = {};
|
||||
try {
|
||||
params = JSON.parse(row.query_params);
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
const entries = Object.entries(params).filter(([, v]) => v != null);
|
||||
if (entries.length === 0) {
|
||||
return <span className="font-mono text-[11px] text-ink-dim">{t('common.allData')}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{entries.map(([k, v]) => `${k}=${v}`).join(', ')}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('exports.colCreated'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completed_at',
|
||||
label: t('exports.colCompleted'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{row.completed_at ? formatTimestamp(row.completed_at) : '\u2014'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
width: '100px',
|
||||
render: (row) =>
|
||||
row.status === 'done' ? (
|
||||
<button
|
||||
className="btn-ghost text-[10px] py-1 px-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(row.export_id, row.query_params);
|
||||
}}
|
||||
>
|
||||
{t('common.download')}
|
||||
</button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('exports.title')}
|
||||
subtitle={t('exports.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('exports.title') },
|
||||
]}
|
||||
actions={
|
||||
<button className="btn-brutalist" onClick={() => setShowCreate(true)}>
|
||||
{t('exports.createExport')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('exports.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={exports}
|
||||
keyExtractor={(row) => row.export_id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('exports.emptyMessage')}
|
||||
/>
|
||||
|
||||
{/* Create Export Modal */}
|
||||
<Modal
|
||||
isOpen={showCreate}
|
||||
onClose={() => {
|
||||
setShowCreate(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
title={t('exports.createExport')}
|
||||
width="max-w-xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{createError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.startTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.endTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.agentIdOptional')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
placeholder={t('exports.filterByAgent')}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.operationTypeOptional')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={operationType}
|
||||
onChange={(e) => setOperationType(e.target.value)}
|
||||
placeholder={t('exports.filterByOperationType')}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.format')}</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="json"
|
||||
checked={format === 'json'}
|
||||
onChange={() => setFormat('json')}
|
||||
className="accent-ink"
|
||||
/>
|
||||
<span className="font-mono text-[13px] text-ink">JSON</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="pdf"
|
||||
checked={format === 'pdf'}
|
||||
onChange={() => setFormat('pdf')}
|
||||
className="accent-ink"
|
||||
/>
|
||||
<span className="font-mono text-[13px] text-ink">PDF</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn-brutalist" onClick={handleCreate} disabled={creating}>
|
||||
{creating ? t('exports.creating') : t('exports.createExport')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
export default function GlobalError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
|
||||
<h2>Something went wrong</h2>
|
||||
<button onClick={() => reset()}>Try again</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200;300;400;500;600;700;800&family=JetBrains+Mono:wght@300;400;500&family=Noto+Sans+SC:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg-color: #EAEAE5;
|
||||
--surface-color: #F2F2EE;
|
||||
--ink-color: #121212;
|
||||
--ink-dim: #666666;
|
||||
--border-color: #C0C0BA;
|
||||
--grid-line: rgba(18, 18, 18, 0.08);
|
||||
--white: #FFFFFF;
|
||||
--sidebar-width: 260px;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-bg: var(--bg-color);
|
||||
--color-surface: var(--surface-color);
|
||||
--color-ink: var(--ink-color);
|
||||
--color-ink-dim: var(--ink-dim);
|
||||
--color-border: var(--border-color);
|
||||
--color-grid-line: var(--grid-line);
|
||||
--font-sans: 'Manrope', 'Noto Sans SC', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--ink-color);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:lang(zh) body,
|
||||
:lang(zh-CN) body,
|
||||
:lang(zh-TW) body {
|
||||
font-family: 'Noto Sans SC', 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--ink-color);
|
||||
}
|
||||
|
||||
/* Grid background */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
/* Crosshair markers */
|
||||
.crosshair {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.crosshair::before, .crosshair::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: var(--ink-color);
|
||||
}
|
||||
.crosshair::before { width: 1px; height: 100%; left: 5px; }
|
||||
.crosshair::after { height: 1px; width: 100%; top: 5px; }
|
||||
|
||||
.ch-tl { top: -12px; left: -12px; }
|
||||
.ch-tr { top: -12px; right: -12px; }
|
||||
.ch-bl { bottom: -12px; left: -12px; }
|
||||
.ch-br { bottom: -12px; right: -12px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.crosshair { display: none; }
|
||||
}
|
||||
|
||||
/* Display text */
|
||||
.display-text {
|
||||
font-size: clamp(2.5rem, 5vw, 4.5rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
/* Console display - smaller variant */
|
||||
.console-display {
|
||||
font-size: clamp(1.5rem, 3vw, 2.5rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
/* Navigation item */
|
||||
.nav-item {
|
||||
position: relative;
|
||||
padding-left: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: var(--ink-color);
|
||||
transform: translateY(-50%) scale(0);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.nav-item:hover {
|
||||
padding-left: 1.5rem;
|
||||
color: var(--ink-color);
|
||||
}
|
||||
.nav-item:hover::before {
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
|
||||
/* Sidebar navigation item */
|
||||
.sidebar-nav-item {
|
||||
position: relative;
|
||||
padding: 8px 16px;
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(234, 234, 229, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-nav-item:hover {
|
||||
color: #EAEAE5;
|
||||
background: rgba(234, 234, 229, 0.05);
|
||||
}
|
||||
.sidebar-nav-item.active {
|
||||
color: #EAEAE5;
|
||||
background: rgba(234, 234, 229, 0.08);
|
||||
}
|
||||
.sidebar-nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
background: #EAEAE5;
|
||||
}
|
||||
|
||||
/* Marquee */
|
||||
.marquee-container {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.marquee-content {
|
||||
display: inline-block;
|
||||
animation: marquee 40s linear infinite;
|
||||
}
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* Brutalist button */
|
||||
.btn-brutalist {
|
||||
background: var(--ink-color);
|
||||
color: var(--bg-color);
|
||||
border: 1px solid var(--ink-color);
|
||||
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 10px 24px;
|
||||
}
|
||||
.btn-brutalist:hover {
|
||||
background: transparent;
|
||||
color: var(--ink-color);
|
||||
}
|
||||
.btn-brutalist:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-brutalist:disabled:hover {
|
||||
background: var(--ink-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
/* Ghost button */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--ink-color);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
cursor: pointer;
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 10px 24px;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-color);
|
||||
border-color: var(--ink-color);
|
||||
}
|
||||
.btn-ghost:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Data row */
|
||||
.data-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.data-row:hover {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Feature card */
|
||||
.feature-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.feature-card:hover {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Stat card */
|
||||
.stat-card {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.stat-card:hover {
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Loading bar animation */
|
||||
@keyframes load {
|
||||
0% { transform: scaleX(0); }
|
||||
50% { transform: scaleX(0.7); }
|
||||
100% { transform: scaleX(0); transform-origin: right; }
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease forwards;
|
||||
}
|
||||
|
||||
/* Stagger children */
|
||||
.stagger-children > * {
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.5s ease forwards;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0.1s; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; }
|
||||
|
||||
/* Skeleton loading */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--border-color) 25%, var(--surface-color) 50%, var(--border-color) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Pulse dot animation */
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.pulse-dot {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Section label */
|
||||
.section-label {
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* Table header */
|
||||
.table-header {
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* Monospace data */
|
||||
.mono-data {
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Mobile-friendly table cells */
|
||||
@media (max-width: 640px) {
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Checkbox/check icon */
|
||||
.check-pass {
|
||||
color: #16a34a;
|
||||
}
|
||||
.check-fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useJWKS } from '@/lib/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import type { JWK } from '@elydora/shared';
|
||||
|
||||
interface JWKRow extends JWK {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function JWKSPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: jwksData, isLoading: jwksLoading, error: jwksError } = useJWKS();
|
||||
const keys = (jwksData?.keys ?? []) as JWKRow[];
|
||||
|
||||
const jwkColumns: Column<JWKRow>[] = [
|
||||
{
|
||||
key: 'kid',
|
||||
label: t('jwks.colKeyId'),
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink font-medium">{row.kid}</span>
|
||||
<CopyButton text={row.kid} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kty',
|
||||
label: t('jwks.colKeyType'),
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.kty}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'alg',
|
||||
label: t('jwks.colAlgorithm'),
|
||||
width: '120px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">{row.alg}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'crv',
|
||||
label: t('jwks.colCurve'),
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">{row.crv ?? '\u2014'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'use',
|
||||
label: t('jwks.colUse'),
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] text-ink-dim uppercase">{row.use}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'x',
|
||||
label: t('jwks.colPublicKey'),
|
||||
render: (row) =>
|
||||
row.x ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{row.x.slice(0, 20)}...
|
||||
</span>
|
||||
<CopyButton text={row.x} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-ink-dim">{'\u2014'}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('jwks.title')}
|
||||
subtitle={t('jwks.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('jwks.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-label">{t('jwks.publicKeys')}</div>
|
||||
{jwksData && (
|
||||
<CopyButton
|
||||
text={JSON.stringify(jwksData, null, 2)}
|
||||
label={t('common.copyJwks')}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jwksError && (
|
||||
<div className="mb-4 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('jwks.failedToLoad')} {jwksError instanceof Error ? jwksError.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={jwkColumns}
|
||||
data={keys}
|
||||
keyExtractor={(row) => row.kid}
|
||||
isLoading={jwksLoading}
|
||||
emptyMessage={t('jwks.emptyMessage')}
|
||||
/>
|
||||
|
||||
<div className="mt-3 font-mono text-[11px] text-ink-dim">
|
||||
{t('jwks.endpoint')} <span className="text-ink">/.well-known/elydora/jwks.json</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import AppShell from '@/components/AppShell';
|
||||
import I18nProvider from '@/components/I18nProvider';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Elydora Console',
|
||||
description: 'Enterprise AI accountability and compliance console',
|
||||
icons: { icon: '/favicon.svg' },
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-bg text-ink font-sans antialiased">
|
||||
<I18nProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await api.auth.login(email, password);
|
||||
login(result.token, { display_name: result.user.display_name, email: result.user.email });
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('login.loginFailed'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-[400px]">
|
||||
{/* Logo */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div className="w-10 h-10 border border-ink flex items-center justify-center">
|
||||
<span className="font-mono text-lg font-bold text-ink">E</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-sans text-base font-semibold tracking-wide text-ink">ELYDORA</div>
|
||||
<div className="font-mono text-[10px] text-ink-dim tracking-widest uppercase">Console</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="border border-border bg-surface p-6 sm:p-8">
|
||||
<h1 className="font-sans text-xl font-semibold tracking-tight text-ink mb-6">{t('login.signIn')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 border border-red-300 bg-red-50 text-red-700 text-sm font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('login.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('login.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full btn-brutalist py-3 text-xs uppercase font-bold tracking-wider disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? t('login.signingIn') : t('login.submitSignIn')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<span className="font-mono text-xs text-ink-dim">{t('login.noAccount')}</span>
|
||||
<Link href="/register" className="font-mono text-xs text-ink underline hover:no-underline">
|
||||
{t('login.createOne')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import OperationDetailClient from '@/components/OperationDetailClient';
|
||||
|
||||
export default function OperationDetailShell() {
|
||||
const [operationId, setOperationId] = useState('');
|
||||
useEffect(() => {
|
||||
setOperationId(window.location.pathname.split('/')[2] ?? '');
|
||||
}, []);
|
||||
if (!operationId) return null;
|
||||
return <OperationDetailClient operationId={operationId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import OperationDetailShell from './client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ operation_id: 'detail' }];
|
||||
}
|
||||
|
||||
export default function OperationDetailPage() {
|
||||
return <OperationDetailShell />;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAudit, formatRelativeTime } from '@/lib/hooks';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import SearchInput from '@/components/ui/SearchInput';
|
||||
import type { Operation } from '@elydora/shared';
|
||||
|
||||
function OperationsContent() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialAgentId = searchParams.get('agent_id') ?? '';
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [agentFilter, setAgentFilter] = useState(initialAgentId);
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data, isLoading, error } = useAudit({
|
||||
agent_id: agentFilter || undefined,
|
||||
operation_type: typeFilter || undefined,
|
||||
cursor,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const operations = data?.operations ?? [];
|
||||
|
||||
const filteredOps = useMemo(() => {
|
||||
if (!search.trim()) return operations;
|
||||
const q = search.toLowerCase();
|
||||
return operations.filter(
|
||||
(op) =>
|
||||
op.operation_id.toLowerCase().includes(q) ||
|
||||
op.agent_id.toLowerCase().includes(q) ||
|
||||
op.operation_type.toLowerCase().includes(q),
|
||||
);
|
||||
}, [operations, search]);
|
||||
|
||||
const columns: Column<Operation & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'operation_id',
|
||||
label: t('operations.colOperationId'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">
|
||||
{row.operation_id.slice(0, 20)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: t('operations.colAgent'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">{row.agent_id}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_type',
|
||||
label: t('operations.colType'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.operation_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'issued_at',
|
||||
label: t('operations.colIssuedAt'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatRelativeTime(row.issued_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seq_no',
|
||||
label: t('operations.colSeq'),
|
||||
sortable: true,
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: Operation & Record<string, unknown>) => {
|
||||
router.push(`/operations/${row.operation_id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('operations.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="mb-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('operations.searchPlaceholder')}
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('operations.agentIdFilter')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentFilter}
|
||||
onChange={(e) => setAgentFilter(e.target.value)}
|
||||
placeholder={t('operations.filterByAgent')}
|
||||
className="w-full px-3 py-2.5 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('operations.typeFilter')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
placeholder={t('operations.filterByType')}
|
||||
className="w-full px-3 py-2.5 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredOps as (Operation & Record<string, unknown>)[]}
|
||||
keyExtractor={(row) => row.operation_id as string}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('operations.emptyMessage')}
|
||||
pagination={
|
||||
data?.cursor
|
||||
? {
|
||||
cursor: data.cursor,
|
||||
hasMore: true,
|
||||
onLoadMore: () => setCursor(data.cursor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('operations.title')}
|
||||
subtitle={t('operations.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('operations.title') },
|
||||
]}
|
||||
/>
|
||||
<Suspense fallback={
|
||||
<div className="border border-border p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-10 w-full max-w-md" />
|
||||
<div className="skeleton h-6 w-full" />
|
||||
<div className="skeleton h-6 w-full" />
|
||||
<div className="skeleton h-6 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<OperationsContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useAgentsList, useAudit, useEpochs, useExports, formatTimestamp, formatRelativeTime } from '@/lib/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import Link from 'next/link';
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
isLoading,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="section-label mb-3">{label}</div>
|
||||
{isLoading ? (
|
||||
<div className="skeleton h-8 w-20 mb-1" />
|
||||
) : (
|
||||
<div className="font-sans text-3xl font-semibold tracking-tight text-ink leading-none">
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="font-mono text-[11px] text-ink-dim mt-2">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: agentsData, isLoading: agentsLoading } = useAgentsList();
|
||||
const { data: auditData, isLoading: auditLoading, error: auditError } = useAudit({});
|
||||
const { data: epochsData, isLoading: epochsLoading } = useEpochs();
|
||||
const { data: exportsData, isLoading: exportsLoading } = useExports();
|
||||
|
||||
const agentsList = agentsData?.agents ?? [];
|
||||
const operations = auditData?.operations ?? [];
|
||||
const recentOps = operations.slice(0, 10);
|
||||
const totalOps = auditData?.total_count ?? 0;
|
||||
const epochs = epochsData?.epochs ?? [];
|
||||
const exports = exportsData?.exports ?? [];
|
||||
const pendingExports = exports.filter((e) => e.status === 'queued' || e.status === 'running');
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('dashboard.title')}
|
||||
subtitle={t('dashboard.subtitle')}
|
||||
/>
|
||||
|
||||
{auditError && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('dashboard.failedToLoad')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8 stagger-children">
|
||||
<StatCard
|
||||
label={t('dashboard.totalAgents')}
|
||||
value={agentsList.length}
|
||||
subtitle={t('dashboard.registeredAgents')}
|
||||
isLoading={agentsLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.totalOperations')}
|
||||
value={totalOps}
|
||||
subtitle={t('dashboard.allRecordedOps')}
|
||||
isLoading={auditLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.activeEpochs')}
|
||||
value={epochs.length}
|
||||
subtitle={t('dashboard.merkleRollups')}
|
||||
isLoading={epochsLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.pendingExports')}
|
||||
value={pendingExports.length}
|
||||
subtitle={t('dashboard.queuedOrRunning')}
|
||||
isLoading={exportsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-8">
|
||||
<div className="section-label mb-3">{t('dashboard.quickActions')}</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/agents" className="btn-brutalist inline-block no-underline">
|
||||
{t('dashboard.registerAgent')}
|
||||
</Link>
|
||||
<Link href="/operations" className="btn-ghost inline-block no-underline">
|
||||
{t('dashboard.viewOperations')}
|
||||
</Link>
|
||||
<Link href="/exports" className="btn-ghost inline-block no-underline">
|
||||
{t('dashboard.createExport')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Operations */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-label">{t('dashboard.recentOperations')}</div>
|
||||
<Link
|
||||
href="/operations"
|
||||
className="font-mono text-[11px] text-ink-dim hover:text-ink transition-colors no-underline uppercase tracking-wider"
|
||||
>
|
||||
{t('common.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="border border-border overflow-hidden">
|
||||
{auditLoading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-4 w-24" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentOps.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="font-mono text-sm text-ink-dim">
|
||||
{t('dashboard.noOperations')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface">
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colOperationId')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colAgent')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colType')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colIssued')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colSeq')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentOps.map((op) => (
|
||||
<tr key={op.operation_id} className="data-row border-b border-border last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/operations/${op.operation_id}`}
|
||||
className="font-mono text-[13px] text-ink hover:underline no-underline"
|
||||
>
|
||||
{op.operation_id.slice(0, 16)}...
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[13px] text-ink-dim">
|
||||
{op.agent_id}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{op.operation_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[12px] text-ink-dim">
|
||||
{formatRelativeTime(op.issued_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[13px] text-ink-dim">
|
||||
#{op.seq_no}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Status Summary */}
|
||||
<div>
|
||||
<div className="section-label mb-3">{t('dashboard.agentSummary')}</div>
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
{agentsLoading ? (
|
||||
<div className="flex gap-8">
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
</div>
|
||||
) : agentsList.length === 0 ? (
|
||||
<p className="font-mono text-sm text-ink-dim">
|
||||
{t('dashboard.noAgentsRegistered')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-8">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('dashboard.totalAgents')}
|
||||
</div>
|
||||
<div className="font-sans text-xl font-semibold text-ink">
|
||||
{agentsList.length}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('dashboard.totalOperations')}
|
||||
</div>
|
||||
<div className="font-sans text-xl font-semibold text-ink">
|
||||
{totalOps}
|
||||
</div>
|
||||
</div>
|
||||
{operations.length > 0 && operations[0] != null && (
|
||||
<div>
|
||||
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('dashboard.latestActivity')}
|
||||
</div>
|
||||
<div className="font-mono text-sm text-ink">
|
||||
{formatTimestamp(operations[0].issued_at)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [orgName, setOrgName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await api.auth.register(email, password, displayName, orgName);
|
||||
login(result.token, { display_name: result.user.display_name, email: result.user.email });
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('register.registrationFailed'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-[400px]">
|
||||
{/* Logo */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div className="w-10 h-10 border border-ink flex items-center justify-center">
|
||||
<span className="font-mono text-lg font-bold text-ink">E</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-sans text-base font-semibold tracking-wide text-ink">ELYDORA</div>
|
||||
<div className="font-mono text-[10px] text-ink-dim tracking-widest uppercase">Console</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="border border-border bg-surface p-8">
|
||||
<h1 className="font-sans text-xl font-semibold tracking-tight text-ink mb-6">{t('register.createAccount')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 border border-red-300 bg-red-50 text-red-700 text-sm font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.orgName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('register.orgPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.displayName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('register.displayNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('register.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('register.passwordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full btn-brutalist py-3 text-xs uppercase font-bold tracking-wider disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? t('register.creatingAccount') : t('register.submitCreateAccount')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<span className="font-mono text-xs text-ink-dim">{t('register.hasAccount')}</span>
|
||||
<Link href="/login" className="font-mono text-xs text-ink underline hover:no-underline">
|
||||
{t('register.signIn')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('settings.title')}
|
||||
subtitle={t('settings.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('settings.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* User Information */}
|
||||
<div className="mb-8">
|
||||
<div className="section-label mb-3">{t('settings.user')}</div>
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.displayName')}
|
||||
</div>
|
||||
<div className="font-sans text-sm text-ink">
|
||||
{user?.display_name ?? '\u2014'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.email')}
|
||||
</div>
|
||||
<div className="font-sans text-sm text-ink">
|
||||
{user?.email ?? '\u2014'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.subject')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink">{user?.sub ?? '\u2014'}</span>
|
||||
{user?.sub && <CopyButton text={user.sub} />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.role')}
|
||||
</div>
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-bg border border-border text-ink">
|
||||
{user?.role ?? '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization */}
|
||||
<div className="mb-8">
|
||||
<div className="section-label mb-3">{t('settings.organization')}</div>
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.organizationId')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink">{user?.org_id ?? '\u2014'}</span>
|
||||
{user?.org_id && <CopyButton text={user.org_id} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign Out */}
|
||||
<div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-4 py-2 font-mono text-[12px] uppercase tracking-wider border border-border bg-surface text-ink hover:bg-bg transition-colors"
|
||||
>
|
||||
{t('common.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user