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>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
integrationsApi,
|
||||
messagesApi,
|
||||
numbersApi,
|
||||
urmiportApi,
|
||||
voiceApi,
|
||||
type AgentMessageApiDoc,
|
||||
type AgentMessageSendResponse,
|
||||
|
|
@ -13,6 +14,8 @@ import {
|
|||
type MessageApiDoc,
|
||||
type PhoneNumber,
|
||||
type SendMessageResponse,
|
||||
type UrmiportApiDoc,
|
||||
type UrmiportPublishResponse,
|
||||
type VoiceApiDocs,
|
||||
type VoiceCall,
|
||||
} from '../../lib/api'
|
||||
|
|
@ -137,34 +140,51 @@ export function ApiExplorerPage() {
|
|||
const [agentTemplate, setAgentTemplate] = useState('')
|
||||
const [agentTo, setAgentTo] = useState('')
|
||||
const [agentCustomerName, setAgentCustomerName] = useState('')
|
||||
const [agentReportGroup, setAgentReportGroup] = useState('')
|
||||
const [agentReportId, setAgentReportId] = useState('')
|
||||
const [agentResponse, setAgentResponse] = useState<AgentMessageSendResponse | null>(null)
|
||||
const [agentError, setAgentError] = useState('')
|
||||
const [agentRunning, setAgentRunning] = useState(false)
|
||||
|
||||
const [urmiportDoc, setUrmiportDoc] = useState<UrmiportApiDoc | null>(null)
|
||||
const [urmiportGroup, setUrmiportGroup] = useState('')
|
||||
const [urmiportReportId, setUrmiportReportId] = useState('')
|
||||
const [urmiportBody, setUrmiportBody] = useState('<html><body><h1>Agent run complete</h1><p>All checks passed.</p></body></html>')
|
||||
const [urmiportAccess, setUrmiportAccess] = useState('public')
|
||||
const [urmiportPassword, setUrmiportPassword] = useState('')
|
||||
const [urmiportResponse, setUrmiportResponse] = useState<UrmiportPublishResponse | null>(null)
|
||||
const [urmiportError, setUrmiportError] = useState('')
|
||||
const [urmiportRunning, setUrmiportRunning] = useState(false)
|
||||
|
||||
const [fromDid, setFromDid] = useState('')
|
||||
const [callTo, setCallTo] = useState('')
|
||||
const [voiceResponse, setVoiceResponse] = useState<VoiceCall | null>(null)
|
||||
const [voiceError, setVoiceError] = useState('')
|
||||
const [voiceRunning, setVoiceRunning] = useState(false)
|
||||
|
||||
const [copied, setCopied] = useState<'sms' | 'voice' | 'agent' | null>(null)
|
||||
const [copied, setCopied] = useState<'sms' | 'voice' | 'agent' | 'urmiport' | null>(null)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
messagesApi.docs(),
|
||||
agentMessagesApi.docs(),
|
||||
urmiportApi.docs(),
|
||||
voiceApi.docs(),
|
||||
integrationsApi.docs(),
|
||||
numbersApi.mine(),
|
||||
])
|
||||
.then((results) => {
|
||||
const [smsResult, agentResult, voiceResult, integrationResult, numbersResult] = results
|
||||
const [smsResult, agentResult, urmiportResult, voiceResult, integrationResult, numbersResult] = results
|
||||
if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value)
|
||||
if (agentResult.status === 'fulfilled') {
|
||||
setAgentDoc(agentResult.value)
|
||||
setAgentTemplate(agentResult.value.request_body.template?.example ?? 'agent_run_completion')
|
||||
setAgentReportGroup(agentResult.value.request_body.report_group?.example ?? 'acme-agent-runs')
|
||||
}
|
||||
if (urmiportResult.status === 'fulfilled') {
|
||||
setUrmiportDoc(urmiportResult.value)
|
||||
setUrmiportGroup(urmiportResult.value.request_body.report_group?.example ?? 'acme-agent-runs')
|
||||
}
|
||||
if (voiceResult.status === 'fulfilled') setVoiceDoc(normalizeVoiceDocs(voiceResult.value))
|
||||
if (integrationResult.status === 'fulfilled') {
|
||||
|
|
@ -206,7 +226,8 @@ export function ApiExplorerPage() {
|
|||
template: agentTemplate || agentDoc.request_body.template.example,
|
||||
to: agentTo || agentDoc.request_body.to.example,
|
||||
customer_name: agentCustomerName || agentDoc.request_body.customer_name.example,
|
||||
report_id: agentReportId || agentDoc.request_body.report_id.example,
|
||||
report_group: agentReportGroup || agentDoc.request_body.report_group?.example || 'acme-agent-runs',
|
||||
report_id: agentReportId || agentDoc.request_body.report_id?.example,
|
||||
}, null, 2)
|
||||
return [
|
||||
`curl -X POST '${API_BASE_URL}${agentDoc.path}' \\`,
|
||||
|
|
@ -214,7 +235,24 @@ export function ApiExplorerPage() {
|
|||
` -H 'Content-Type: application/json' \\`,
|
||||
` -d '${body.replace(/'/g, `'\\''`)}'`,
|
||||
].join('\n')
|
||||
}, [agentCustomerName, agentDoc, agentReportId, agentTemplate, agentTo, apiKey])
|
||||
}, [agentCustomerName, agentDoc, agentReportGroup, agentReportId, agentTemplate, agentTo, apiKey])
|
||||
|
||||
const urmiportCurl = useMemo(() => {
|
||||
if (!urmiportDoc) return ''
|
||||
const body = JSON.stringify({
|
||||
report_group: urmiportGroup || urmiportDoc.request_body.report_group.example,
|
||||
report_id: urmiportReportId || urmiportDoc.request_body.report_id?.example,
|
||||
body: urmiportBody,
|
||||
access: urmiportAccess,
|
||||
password: urmiportAccess === 'password' ? urmiportPassword || urmiportDoc.request_body.password?.example : undefined,
|
||||
}, null, 2)
|
||||
return [
|
||||
`curl -X POST '${API_BASE_URL}${urmiportDoc.path}' \\`,
|
||||
` -H 'Authorization: Bearer ${apiKey || 'urmi_live_<your_api_key>'}' \\`,
|
||||
` -H 'Content-Type: application/json' \\`,
|
||||
` -d '${body.replace(/'/g, `'\\''`)}'`,
|
||||
].join('\n')
|
||||
}, [apiKey, urmiportAccess, urmiportBody, urmiportDoc, urmiportGroup, urmiportPassword, urmiportReportId])
|
||||
|
||||
const voiceCurl = useMemo(() => {
|
||||
if (!voiceDoc) return ''
|
||||
|
|
@ -259,6 +297,7 @@ export function ApiExplorerPage() {
|
|||
template: agentTemplate.trim(),
|
||||
to: agentTo.trim(),
|
||||
customer_name: agentCustomerName.trim(),
|
||||
report_group: agentReportGroup.trim(),
|
||||
report_id: agentReportId.trim() || undefined,
|
||||
})
|
||||
setAgentResponse(result)
|
||||
|
|
@ -287,8 +326,28 @@ export function ApiExplorerPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function copyCurl(kind: 'sms' | 'voice' | 'agent') {
|
||||
const text = kind === 'sms' ? smsCurl : kind === 'agent' ? agentCurl : voiceCurl
|
||||
async function runUrmiport() {
|
||||
setUrmiportError('')
|
||||
setUrmiportResponse(null)
|
||||
setUrmiportRunning(true)
|
||||
try {
|
||||
const result = await urmiportApi.publish(apiKey.trim(), {
|
||||
report_group: urmiportGroup.trim(),
|
||||
report_id: urmiportReportId.trim() || undefined,
|
||||
body: urmiportBody,
|
||||
access: urmiportAccess as 'public' | 'password',
|
||||
password: urmiportAccess === 'password' ? urmiportPassword.trim() || undefined : undefined,
|
||||
})
|
||||
setUrmiportResponse(result)
|
||||
} catch (reason) {
|
||||
setUrmiportError(reason instanceof Error ? reason.message : 'Request failed')
|
||||
} finally {
|
||||
setUrmiportRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCurl(kind: 'sms' | 'voice' | 'agent' | 'urmiport') {
|
||||
const text = kind === 'sms' ? smsCurl : kind === 'agent' ? agentCurl : kind === 'urmiport' ? urmiportCurl : voiceCurl
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(kind)
|
||||
window.setTimeout(() => setCopied(null), 2000)
|
||||
|
|
@ -425,12 +484,14 @@ export function ApiExplorerPage() {
|
|||
key === 'template' ? agentTemplate
|
||||
: key === 'to' ? agentTo
|
||||
: key === 'customer_name' ? agentCustomerName
|
||||
: agentReportId
|
||||
: key === 'report_group' ? agentReportGroup
|
||||
: agentReportId
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (key === 'template') setAgentTemplate(e.target.value)
|
||||
if (key === 'to') setAgentTo(e.target.value)
|
||||
if (key === 'customer_name') setAgentCustomerName(e.target.value)
|
||||
if (key === 'report_group') setAgentReportGroup(e.target.value)
|
||||
if (key === 'report_id') setAgentReportId(e.target.value)
|
||||
}}
|
||||
placeholder={field.example}
|
||||
|
|
@ -475,6 +536,100 @@ export function ApiExplorerPage() {
|
|||
</ExplorerGroup>
|
||||
)}
|
||||
|
||||
{urmiportDoc && (
|
||||
<ExplorerGroup title="Urmiport">
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3>{urmiportDoc.title}</h3>
|
||||
<p>{urmiportDoc.summary}</p>
|
||||
</div>
|
||||
<span className="explorer-method">{urmiportDoc.method}</span>
|
||||
</div>
|
||||
|
||||
<FieldRow label="Endpoint" value={`${API_BASE_URL}${urmiportDoc.path}`} />
|
||||
<FieldRow label="Auth" value={`${urmiportDoc.authentication.header}: ${urmiportDoc.authentication.format}`} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Request body</h4>
|
||||
<div className="explorer-request-grid">
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>report_group</strong>
|
||||
<span>{urmiportDoc.request_body.report_group.description}</span>
|
||||
<input value={urmiportGroup} onChange={(e) => setUrmiportGroup(e.target.value)} placeholder={urmiportDoc.request_body.report_group.example} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>report_id</strong>
|
||||
<span>{urmiportDoc.request_body.report_id.description}</span>
|
||||
<input value={urmiportReportId} onChange={(e) => setUrmiportReportId(e.target.value)} placeholder={urmiportDoc.request_body.report_id.example} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input explorer-input-wide">
|
||||
<label>
|
||||
<strong>body</strong>
|
||||
<span>{urmiportDoc.request_body.body.description}</span>
|
||||
<textarea value={urmiportBody} onChange={(e) => setUrmiportBody(e.target.value)} rows={6} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>access</strong>
|
||||
<span>{urmiportDoc.request_body.access.description}</span>
|
||||
<select value={urmiportAccess} onChange={(e) => setUrmiportAccess(e.target.value)}>
|
||||
<option value="public">public</option>
|
||||
<option value="password">password</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{urmiportAccess === 'password' && (
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>password</strong>
|
||||
<span>{urmiportDoc.request_body.password.description}</span>
|
||||
<input type="password" value={urmiportPassword} onChange={(e) => setUrmiportPassword(e.target.value)} placeholder={urmiportDoc.request_body.password.example} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>API key</h4>
|
||||
<label className="explorer-input">
|
||||
<strong>Bearer token</strong>
|
||||
<span>Create the report group in Agent → Urmiport before publishing.</span>
|
||||
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="explorer-actions">
|
||||
<button className="primary-button" onClick={runUrmiport} disabled={urmiportRunning || !apiKey.trim()}>
|
||||
<Play size={15} /> {urmiportRunning ? 'Publishing…' : 'Try request'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => copyCurl('urmiport')}>
|
||||
<Copy size={15} /> {copied === 'urmiport' ? 'Copied cURL' : 'Copy cURL'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{urmiportError && <p className="auth-error">{urmiportError}</p>}
|
||||
{urmiportResponse && (
|
||||
<div className="explorer-response success">
|
||||
<strong><CheckCircle2 size={15} /> 200 OK</strong>
|
||||
<pre>{JSON.stringify(urmiportResponse, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example cURL</h4>
|
||||
<pre className="explorer-code">{urmiportCurl}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</ExplorerGroup>
|
||||
)}
|
||||
|
||||
{voiceDoc && integrationDocs && (
|
||||
<ExplorerGroup title="Numbers">
|
||||
<RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} />
|
||||
|
|
|
|||
80
frontend/src/features/reports/PublicReportPage.tsx
Normal file
80
frontend/src/features/reports/PublicReportPage.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { urmiportApi } from '../../lib/api'
|
||||
|
||||
export function PublicReportPage() {
|
||||
const { group, reportId } = useParams<{ group: string; reportId: string }>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [requiresPassword, setRequiresPassword] = useState(false)
|
||||
const [html, setHtml] = useState<string | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!group || !reportId) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
urmiportApi.viewReport(group, reportId)
|
||||
.then((payload) => {
|
||||
setRequiresPassword(payload.requires_password)
|
||||
setHtml(payload.html)
|
||||
})
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Report not found'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [group, reportId])
|
||||
|
||||
async function unlock(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!group || !reportId) return
|
||||
setError('')
|
||||
try {
|
||||
const payload = await urmiportApi.unlockReport(group, reportId, password)
|
||||
setHtml(payload.html)
|
||||
setRequiresPassword(false)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Invalid password')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="public-report-loading">Loading report…</div>
|
||||
}
|
||||
|
||||
if (error && !requiresPassword) {
|
||||
return <div className="public-report-error">{error}</div>
|
||||
}
|
||||
|
||||
if (requiresPassword && !html) {
|
||||
return (
|
||||
<div className="public-report-gate">
|
||||
<form onSubmit={unlock}>
|
||||
<h1>Password required</h1>
|
||||
<p>This report is protected. Enter the password to continue.</p>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Password"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
{error && <p className="public-report-error-text">{error}</p>}
|
||||
<button type="submit">View report</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
return <div className="public-report-error">Report not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="public-report-frame"
|
||||
title="Report"
|
||||
srcDoc={html}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue