reports
This commit is contained in:
parent
d272ae0bd3
commit
ff01efe4bb
17 changed files with 1378 additions and 21 deletions
|
|
@ -14,6 +14,7 @@ function TestModal({
|
|||
}) {
|
||||
const [to, setTo] = useState('')
|
||||
const [customerName, setCustomerName] = useState('')
|
||||
const [reportGroup, setReportGroup] = useState('')
|
||||
const [reportId, setReportId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
|
@ -26,6 +27,7 @@ function TestModal({
|
|||
await agentTemplatesApi.test(template.slug, {
|
||||
to: to.trim(),
|
||||
customer_name: customerName.trim(),
|
||||
report_group: reportGroup.trim(),
|
||||
report_id: reportId.trim() || undefined,
|
||||
})
|
||||
onSent()
|
||||
|
|
@ -56,10 +58,15 @@ function TestModal({
|
|||
<span>Customer name</span>
|
||||
<input value={customerName} onChange={(e) => setCustomerName(e.target.value)} placeholder="Suyash" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Report group</span>
|
||||
<input value={reportGroup} onChange={(e) => setReportGroup(e.target.value)} placeholder="acme-agent-runs" required />
|
||||
<small>Urmiport group slug used in urmiai.com/reports/{'{group}'}/{'{id}'}.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Report ID (optional)</span>
|
||||
<input value={reportId} onChange={(e) => setReportId(e.target.value)} placeholder="run-abc123" />
|
||||
<small>Used for the dynamic View Report button URL suffix.</small>
|
||||
<small>Report postfix; auto-generated when omitted.</small>
|
||||
</label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</div>
|
||||
|
|
|
|||
284
frontend/src/features/agent/UrmiportPage.tsx
Normal file
284
frontend/src/features/agent/UrmiportPage.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { ArrowRight, Code2, ExternalLink, FileText, Globe, Lock, Plus, X } from 'lucide-react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { urmiportApi, type UrmiportApiDoc, type UrmiportGroup, type UrmiportReport } from '../../lib/api'
|
||||
|
||||
const GROUP_SLUG_RE = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/
|
||||
|
||||
function GroupModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated: (group: UrmiportGroup) => void
|
||||
}) {
|
||||
const [reportGroup, setReportGroup] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [defaultAccess, setDefaultAccess] = useState<'public' | 'password'>('public')
|
||||
const [password, setPassword] = useState('')
|
||||
const [idMode, setIdMode] = useState<'auto' | 'manual'>('auto')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
const slug = reportGroup.trim().toLowerCase()
|
||||
if (!GROUP_SLUG_RE.test(slug) || slug.includes('--')) {
|
||||
setError('Report group must be 3–50 chars: lowercase letters, numbers, hyphens')
|
||||
return
|
||||
}
|
||||
if (defaultAccess === 'password' && !password.trim()) {
|
||||
setError('Password is required for password-protected groups')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const group = await urmiportApi.createGroup({
|
||||
report_group: slug,
|
||||
display_name: displayName.trim() || undefined,
|
||||
default_access: defaultAccess,
|
||||
password: defaultAccess === 'password' ? password.trim() : undefined,
|
||||
id_mode: idMode,
|
||||
})
|
||||
onCreated(group)
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to create report group')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<form className="modal-card otp-project-modal" onSubmit={submit} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Create report group</h2>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="otp-project-form">
|
||||
<label>
|
||||
<span>Report group</span>
|
||||
<input
|
||||
value={reportGroup}
|
||||
onChange={(event) => setReportGroup(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder="acme-agent-runs"
|
||||
required
|
||||
/>
|
||||
<small>Used in URLs: urmiai.com/reports/{'{report_group}'}/{'{report_id}'}</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Display name (optional)</span>
|
||||
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="Acme agent runs" />
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Default access</legend>
|
||||
<label><input type="radio" checked={defaultAccess === 'public'} onChange={() => setDefaultAccess('public')} /> Public</label>
|
||||
<label><input type="radio" checked={defaultAccess === 'password'} onChange={() => setDefaultAccess('password')} /> Password</label>
|
||||
</fieldset>
|
||||
{defaultAccess === 'password' && (
|
||||
<label>
|
||||
<span>Default password</span>
|
||||
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} required />
|
||||
</label>
|
||||
)}
|
||||
<fieldset>
|
||||
<legend>Report ID mode</legend>
|
||||
<label><input type="radio" checked={idMode === 'auto'} onChange={() => setIdMode('auto')} /> Auto-generate postfix</label>
|
||||
<label><input type="radio" checked={idMode === 'manual'} onChange={() => setIdMode('manual')} /> Require report_id in API</label>
|
||||
</fieldset>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="primary-button" disabled={saving}>{saving ? 'Creating…' : 'Create group'}</button>
|
||||
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UrmiportPage() {
|
||||
const [groups, setGroups] = useState<UrmiportGroup[]>([])
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
|
||||
const [reports, setReports] = useState<UrmiportReport[]>([])
|
||||
const [docs, setDocs] = useState<UrmiportApiDoc | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [reportsLoading, setReportsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([urmiportApi.listGroups(), urmiportApi.docs()])
|
||||
.then(([groupList, apiDocs]) => {
|
||||
setGroups(groupList)
|
||||
setDocs(apiDocs)
|
||||
if (groupList[0]) setSelectedGroup(groupList[0].report_group)
|
||||
})
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load Urmiport'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGroup) {
|
||||
setReports([])
|
||||
return
|
||||
}
|
||||
setReportsLoading(true)
|
||||
urmiportApi.listReports(selectedGroup)
|
||||
.then(setReports)
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load reports'))
|
||||
.finally(() => setReportsLoading(false))
|
||||
}, [selectedGroup])
|
||||
|
||||
return (
|
||||
<div className="urmiport-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Agent</span>
|
||||
<h1><FileText size={24} /> Urmiport</h1>
|
||||
<p>Host HTML reports at urmiai.com/reports/{'{report_group}'}/{'{report_id}'} — no Urmi branding on the public page.</p>
|
||||
</div>
|
||||
<button className="primary-button" onClick={() => setShowModal(true)}>
|
||||
<Plus size={16} /> New report group
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
|
||||
<section className="panel urmiport-groups-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Report groups</h2>
|
||||
<p>Each group is a namespace for hosted reports. Publish HTML via the API below.</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="panel-empty">Loading…</p>
|
||||
) : !groups.length ? (
|
||||
<p className="panel-empty">Create a report group to start hosting reports.</p>
|
||||
) : (
|
||||
<div className="urmiport-group-table">
|
||||
<div className="urmiport-group-table-head">
|
||||
<span>Group</span>
|
||||
<span>Access</span>
|
||||
<span>ID mode</span>
|
||||
<span>Reports</span>
|
||||
<span>URL pattern</span>
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
type="button"
|
||||
key={group.id}
|
||||
className={`urmiport-group-row ${selectedGroup === group.report_group ? 'is-selected' : ''}`}
|
||||
onClick={() => setSelectedGroup(group.report_group)}
|
||||
>
|
||||
<div>
|
||||
<strong>{group.display_name || group.report_group}</strong>
|
||||
<small>{group.report_group}</small>
|
||||
</div>
|
||||
<span>{group.default_access === 'password' ? <><Lock size={13} /> Password</> : <><Globe size={13} /> Public</>}</span>
|
||||
<span>{group.id_mode === 'auto' ? 'Auto' : 'Manual'}</span>
|
||||
<span>{group.report_count}</span>
|
||||
<code>{group.url_pattern}</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selectedGroup && (
|
||||
<section className="panel urmiport-reports-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Reports in {selectedGroup}</h2>
|
||||
<p>Published via API. Same report_group + report_id updates the HTML body.</p>
|
||||
</div>
|
||||
</div>
|
||||
{reportsLoading ? (
|
||||
<p className="panel-empty">Loading reports…</p>
|
||||
) : !reports.length ? (
|
||||
<p className="panel-empty">No reports yet. Publish one using the API docs below.</p>
|
||||
) : (
|
||||
<div className="urmiport-report-table">
|
||||
<div className="urmiport-report-table-head">
|
||||
<span>Report ID</span>
|
||||
<span>Access</span>
|
||||
<span>Updated</span>
|
||||
<span>URL</span>
|
||||
</div>
|
||||
{reports.map((report) => (
|
||||
<div className="urmiport-report-row" key={report.id}>
|
||||
<strong>{report.report_id}</strong>
|
||||
<span>{report.access === 'password' ? 'Password' : 'Public'}</span>
|
||||
<span>{new Date(report.updated_at).toLocaleString()}</span>
|
||||
<a href={report.url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink size={13} /> Open
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{docs && (
|
||||
<section className="panel urmiport-docs-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2><Code2 size={18} /> API documentation</h2>
|
||||
<p>{docs.summary}</p>
|
||||
</div>
|
||||
<span className="explorer-method">{docs.method}</span>
|
||||
</div>
|
||||
<div className="urmiport-docs-grid">
|
||||
<div>
|
||||
<h3>Endpoint</h3>
|
||||
<code>POST /api/v1/urmiport/reports</code>
|
||||
<h3>Authentication</h3>
|
||||
<p><code>{docs.authentication.header}: {docs.authentication.format}</code></p>
|
||||
<h3>Request body</h3>
|
||||
<ul className="agent-template-notes">
|
||||
{Object.entries(docs.request_body).map(([key, field]) => (
|
||||
<li key={key}>
|
||||
<strong>{key}</strong>{field.required ? ' (required)' : ' (optional)'} — {field.description}
|
||||
{field.example ? <> Example: <code>{field.example}</code></> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3>Notes</h3>
|
||||
<ul className="agent-template-notes">
|
||||
{docs.notes?.map((note) => <li key={note}>{note}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Example request</h3>
|
||||
<pre className="explorer-code">{JSON.stringify({
|
||||
report_group: 'acme-agent-runs',
|
||||
report_id: 'run-abc123',
|
||||
body: '<html><body><h1>Agent run complete</h1></body></html>',
|
||||
access: 'public',
|
||||
}, null, 2)}</pre>
|
||||
<h3>Example response</h3>
|
||||
<pre className="explorer-code">{JSON.stringify(docs.responses['200']?.example ?? {}, null, 2)}</pre>
|
||||
<Link to="/console/api-explorer" className="text-button">
|
||||
Try in API Explorer <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<GroupModal
|
||||
onClose={() => setShowModal(false)}
|
||||
onCreated={(group) => {
|
||||
setGroups((current) => [group, ...current])
|
||||
setSelectedGroup(group.report_group)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue