89aa2c47cb
Deploy to Production / deploy (push) Successful in 3m16s
Registry: new GET /services/browse endpoint — paginated, no auth required,
returns ServiceBrowsePage (total, page, size, items). ServiceSummaryDto
extended with description, registrantName, registrantJurisdiction; lastCheckedAt
removed (not stored). ServiceBrowsePage added to apix-common as shared type.
Portal: ServiceBrowserResource at /browse (list + /browse/{id} detail);
ServiceRegistryClient for the new endpoints; PortalServiceView DTO for detail
deserialization; three Qute templates (browse, detail, error) in the existing
dark-theme design. Landing page links to /browse in the "Try it" section.
Architecture: registry stays pure JSON API; portal is the HTML layer over the
same data — two subdomains, no content negotiation required.
Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
303 lines
7.7 KiB
HTML
303 lines
7.7 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>APIX Registry · Browse Services</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
background: #0d1117;
|
|
color: #c9d1d9;
|
|
font-family: 'SF Mono', 'Consolas', 'Fira Code', monospace;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
}
|
|
a { color: #58a6ff; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
|
|
/* ── Header ── */
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid #21262d;
|
|
}
|
|
.header-logo { color: #8b949e; font-size: 0.8rem; }
|
|
.header-title { font-size: 1rem; color: #e6edf3; font-weight: 600; }
|
|
|
|
/* ── Search ── */
|
|
.search-bar {
|
|
padding: 1.25rem 1.5rem;
|
|
border-bottom: 1px solid #21262d;
|
|
display: flex;
|
|
gap: 0.6rem;
|
|
align-items: center;
|
|
}
|
|
.search-input {
|
|
flex: 1;
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
color: #c9d1d9;
|
|
padding: 0.5rem 0.75rem;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
border-radius: 4px;
|
|
outline: none;
|
|
}
|
|
.search-input:focus { border-color: #58a6ff; }
|
|
.search-input::placeholder { color: #484f58; }
|
|
.btn {
|
|
background: #21262d;
|
|
border: 1px solid #30363d;
|
|
color: #c9d1d9;
|
|
padding: 0.45rem 0.9rem;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
white-space: nowrap;
|
|
}
|
|
.btn:hover { border-color: #58a6ff; color: #58a6ff; }
|
|
.btn-link { background: transparent; border: none; color: #484f58; cursor: pointer; font-family: inherit; font-size: 13px; }
|
|
.btn-link:hover { color: #58a6ff; }
|
|
|
|
/* ── Stats bar ── */
|
|
.stats-bar {
|
|
padding: 0.45rem 1.5rem;
|
|
font-size: 0.7rem;
|
|
color: #484f58;
|
|
border-bottom: 1px solid #21262d;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
/* ── Service list ── */
|
|
.service-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
background: #21262d;
|
|
}
|
|
|
|
.service-card {
|
|
background: #0d1117;
|
|
padding: 1rem 1.5rem;
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 0.75rem;
|
|
align-items: start;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
.service-card:hover { background: #161b22; }
|
|
|
|
.card-name {
|
|
color: #e6edf3;
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
}
|
|
.card-name a { color: #e6edf3; }
|
|
.card-name a:hover { color: #58a6ff; text-decoration: none; }
|
|
|
|
.card-desc {
|
|
color: #8b949e;
|
|
font-size: 0.78rem;
|
|
margin-top: 0.2rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 72ch;
|
|
}
|
|
|
|
.caps {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.25rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.cap-tag {
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
color: #8b949e;
|
|
padding: 0.05rem 0.4rem;
|
|
border-radius: 3px;
|
|
font-size: 0.65rem;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.card-meta {
|
|
font-size: 0.68rem;
|
|
color: #484f58;
|
|
margin-top: 0.45rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
/* ── Liveness dot ── */
|
|
.dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
flex-shrink: 0;
|
|
}
|
|
.dot-LIVE { background: #3fb950; box-shadow: 0 0 5px #3fb95088; }
|
|
.dot-DEGRADED { background: #d29922; }
|
|
.dot-UNREACHABLE { background: #f85149; }
|
|
.dot-PENDING { background: #484f58; }
|
|
|
|
/* ── O-level badge ── */
|
|
.o-badge {
|
|
font-size: 0.62rem;
|
|
padding: 0.15rem 0.5rem;
|
|
border-radius: 10px;
|
|
border: 1px solid;
|
|
white-space: nowrap;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
align-self: flex-start;
|
|
margin-top: 0.15rem;
|
|
}
|
|
.o-UNVERIFIED { color: #484f58; border-color: #30363d; background: transparent; }
|
|
.o-IDENTITY_VERIFIED { color: #d29922; border-color: #d2992255; background: #d2992211; }
|
|
.o-LEGAL_ENTITY_VERIFIED { color: #e3b341; border-color: #e3b34155; background: #e3b34111; }
|
|
.o-HYGIENE_VERIFIED { color: #58a6ff; border-color: #58a6ff55; background: #58a6ff11; }
|
|
.o-OPERATIONALLY_VERIFIED { color: #3fb950; border-color: #3fb95055; background: #3fb95011; }
|
|
.o-AUDITED { color: #bc8cff; border-color: #bc8cff55; background: #bc8cff11; }
|
|
|
|
/* ── Pagination ── */
|
|
.pagination {
|
|
padding: 1rem 1.5rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-top: 1px solid #21262d;
|
|
font-size: 0.78rem;
|
|
color: #484f58;
|
|
}
|
|
.page-btn { display: inline-block; color: #8b949e; border: 1px solid #30363d; padding: 0.3rem 0.65rem; border-radius: 4px; }
|
|
.page-btn:hover { border-color: #58a6ff; color: #58a6ff; text-decoration: none; }
|
|
.page-btn-off { color: #30363d; border: 1px solid #21262d; padding: 0.3rem 0.65rem; border-radius: 4px; }
|
|
|
|
/* ── Empty state ── */
|
|
.empty {
|
|
padding: 4rem 1.5rem;
|
|
text-align: center;
|
|
color: #484f58;
|
|
}
|
|
.empty h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #30363d; }
|
|
|
|
/* ── Footer ── */
|
|
.footer {
|
|
padding: 1rem 1.5rem;
|
|
border-top: 1px solid #21262d;
|
|
font-size: 0.7rem;
|
|
color: #484f58;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header">
|
|
<span class="header-logo"><a href="/browse">APIX</a></span>
|
|
<span class="header-title">Service Registry</span>
|
|
</div>
|
|
|
|
<form class="search-bar" method="get" action="/browse">
|
|
<input class="search-input" type="text" name="capability" value="{capability}"
|
|
placeholder="search by capability (e.g. nlp, translation, speech-to-text)"
|
|
autocomplete="off" spellcheck="false">
|
|
<button class="btn" type="submit">Search</button>
|
|
{#if capability.isEmpty() == false}
|
|
<a href="/browse" class="btn-link">clear</a>
|
|
{/if}
|
|
</form>
|
|
|
|
<div class="stats-bar">
|
|
{page.total} service{#if page.total != 1}s{/if}
|
|
{#if capability.isEmpty()}in production · public registry{#else}matching «{capability}»{/if}
|
|
</div>
|
|
|
|
{#if page.items.isEmpty()}
|
|
<div class="empty">
|
|
<h2>no services found</h2>
|
|
<p>
|
|
{#if capability.isEmpty()}
|
|
No production services are registered yet.
|
|
{#else}
|
|
No production services match capability «{capability}».
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
{#else}
|
|
<div class="service-list">
|
|
{#for s in page.items}
|
|
<div class="service-card" onclick="location.href='/browse/{s.id}'">
|
|
<div>
|
|
<div class="card-name"><a href="/browse/{s.id}">{s.name}</a></div>
|
|
{#if s.description != null}
|
|
<div class="card-desc">{s.description}</div>
|
|
{/if}
|
|
{#if s.capabilities != null && s.capabilities.isEmpty() == false}
|
|
<div class="caps">
|
|
{#for cap in s.capabilities}
|
|
<span class="cap-tag">{cap}</span>
|
|
{/for}
|
|
</div>
|
|
{/if}
|
|
<div class="card-meta">
|
|
<span class="dot dot-{s.livenessStatus}"></span>
|
|
<span>{s.livenessStatus}</span>
|
|
{#if s.registrantName != null}
|
|
<span>·</span>
|
|
<span>{s.registrantName}</span>
|
|
{/if}
|
|
{#if s.registrantJurisdiction != null}
|
|
<span>·</span>
|
|
<span>{s.registrantJurisdiction}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span class="o-badge o-{s.oLevel}">{s.oLevel}</span>
|
|
</div>
|
|
</div>
|
|
{/for}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="pagination">
|
|
<span>
|
|
{#if hasPrev}
|
|
<a href="/browse?page={prevPage}{#if capability.isEmpty() == false}&capability={capability}{/if}" class="page-btn">← prev</a>
|
|
{#else}
|
|
<span class="page-btn-off">← prev</span>
|
|
{/if}
|
|
</span>
|
|
<span>page {currentPage + 1} of {totalPages}</span>
|
|
<span>
|
|
{#if hasNext}
|
|
<a href="/browse?page={nextPage}{#if capability.isEmpty() == false}&capability={capability}{/if}" class="page-btn">next →</a>
|
|
{#else}
|
|
<span class="page-btn-off">next →</span>
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<span>APIX Registry · open protocol</span>
|
|
<span>
|
|
<a href="https://api-index.org">api-index.org</a> ·
|
|
<a href="https://api-index.org/q/openapi">OpenAPI</a>
|
|
</span>
|
|
</div>
|
|
|
|
</body>
|
|
</html>
|