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:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-02 13:41:42 -05:00
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 />;
}
+248
View File
@@ -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">&mdash;</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">&mdash;</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>
);
}
+238
View File
@@ -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 />;
}
+126
View File
@@ -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>
);
}
+296
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+355
View File
@@ -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;
}
+125
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+232
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}