- veins → shunts rename - add cfg/standalone/ and cfg/<room>/ structure - remove old data/*.json (moved to cfg/<room>/data/) - update build.py and ctrl scripts
1493 lines
56 KiB
HTML
1493 lines
56 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Artery · Soleprint</title>
|
|
<link
|
|
rel="icon"
|
|
type="image/svg+xml"
|
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='%23b91c1c' stroke-width='2.5'%3E%3Cpath d='M24 4 L24 20 M24 20 L8 40 M24 20 L40 40'/%3E%3Ccircle cx='24' cy='4' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='8' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='40' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='24' cy='20' r='5' fill='none'/%3E%3Ccircle cx='24' cy='20' r='2' fill='%23b91c1c'/%3E%3C/svg%3E"
|
|
/>
|
|
<style>
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
html {
|
|
background: #0a0a0a;
|
|
}
|
|
body {
|
|
font-family:
|
|
system-ui,
|
|
-apple-system,
|
|
sans-serif;
|
|
max-width: 960px;
|
|
margin: 0 auto;
|
|
padding: 2rem 1rem;
|
|
line-height: 1.6;
|
|
color: #e5e5e5;
|
|
background: #b91c1c;
|
|
}
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.logo {
|
|
width: 64px;
|
|
height: 64px;
|
|
color: white;
|
|
}
|
|
h1 {
|
|
font-size: 2.5rem;
|
|
margin: 0;
|
|
color: white;
|
|
}
|
|
.tagline {
|
|
color: rgba(255, 255, 255, 0.85);
|
|
margin-bottom: 2rem;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
|
padding-bottom: 2rem;
|
|
}
|
|
section {
|
|
background: #0a0a0a;
|
|
padding: 1.5rem;
|
|
margin: 1.5rem 0;
|
|
border-radius: 12px;
|
|
}
|
|
section h2 {
|
|
margin: 0 0 1rem 0;
|
|
font-size: 1.2rem;
|
|
color: #fca5a5;
|
|
}
|
|
.composition {
|
|
background: #1a1a1a;
|
|
border: 2px solid #b91c1c;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
}
|
|
.composition h3 {
|
|
margin: 0 0 0.75rem 0;
|
|
font-size: 1.1rem;
|
|
color: #fca5a5;
|
|
}
|
|
.composition > p {
|
|
margin: 0 0 1rem 0;
|
|
font-size: 0.9rem;
|
|
color: #a3a3a3;
|
|
}
|
|
.components {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
gap: 0.75rem;
|
|
}
|
|
.component {
|
|
background: #0a0a0a;
|
|
border: 1px solid #3f3f3f;
|
|
padding: 0.75rem;
|
|
border-radius: 8px;
|
|
}
|
|
.component h4 {
|
|
margin: 0 0 0.25rem 0;
|
|
font-size: 0.95rem;
|
|
color: #fca5a5;
|
|
}
|
|
.component p {
|
|
margin: 0;
|
|
font-size: 0.85rem;
|
|
color: #a3a3a3;
|
|
}
|
|
.veins {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
.vein {
|
|
background: #1a1a1a;
|
|
border: 1px solid #3f3f3f;
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.vein:hover {
|
|
background: #2a2a2a;
|
|
}
|
|
.vein.selected {
|
|
border-color: #b91c1c;
|
|
border-width: 2px;
|
|
background: #1a1a1a;
|
|
}
|
|
.vein.active {
|
|
background: #b91c1c;
|
|
border-color: #b91c1c;
|
|
}
|
|
.vein.active h3 {
|
|
color: white;
|
|
}
|
|
.vein.active:hover {
|
|
background: #991b1b;
|
|
}
|
|
.vein.active.selected {
|
|
background: #991b1b;
|
|
border-color: white;
|
|
}
|
|
.vein.disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
.vein.disabled:hover {
|
|
background: #1a1a1a;
|
|
}
|
|
.vein h3 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
color: #e5e5e5;
|
|
}
|
|
.endpoints {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.endpoints li {
|
|
padding: 0.75rem 0;
|
|
border-bottom: 1px solid #3f3f3f;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
.endpoints li:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.endpoints code {
|
|
font-family: monospace;
|
|
background: #1a1a1a;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 4px;
|
|
color: #fca5a5;
|
|
}
|
|
.endpoints .desc {
|
|
color: #a3a3a3;
|
|
font-size: 0.9rem;
|
|
}
|
|
code {
|
|
background: #2a2a2a;
|
|
color: #fca5a5;
|
|
padding: 0.1rem 0.3rem;
|
|
border-radius: 3px;
|
|
font-size: 0.85rem;
|
|
}
|
|
footer {
|
|
margin-top: 3rem;
|
|
padding-top: 1.5rem;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
|
font-size: 0.85rem;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
}
|
|
footer a {
|
|
color: white;
|
|
}
|
|
footer .disabled {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Tab content */
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
.tab-content.visible {
|
|
display: block;
|
|
}
|
|
|
|
/* Jira form */
|
|
.api-form {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.api-form label {
|
|
display: block;
|
|
font-weight: 500;
|
|
margin-bottom: 0.5rem;
|
|
color: #e5e5e5;
|
|
}
|
|
.api-form input[type="text"] {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
border: 1px solid #3f3f3f;
|
|
border-radius: 8px;
|
|
font-family: monospace;
|
|
font-size: 0.9rem;
|
|
background: #1a1a1a;
|
|
color: #e5e5e5;
|
|
}
|
|
.api-form input[type="text"]:focus,
|
|
.api-form select:focus {
|
|
outline: none;
|
|
border-color: #b91c1c;
|
|
}
|
|
.api-form select {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
border: 1px solid #3f3f3f;
|
|
border-radius: 8px;
|
|
font-size: 0.9rem;
|
|
background: #1a1a1a;
|
|
color: #e5e5e5;
|
|
cursor: pointer;
|
|
}
|
|
.api-form select:disabled {
|
|
background: #0a0a0a;
|
|
color: #666;
|
|
cursor: not-allowed;
|
|
}
|
|
.api-form input:disabled {
|
|
background: #0a0a0a;
|
|
color: #666;
|
|
cursor: not-allowed;
|
|
}
|
|
.api-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
.api-controls button {
|
|
background: #b91c1c;
|
|
color: white;
|
|
border: none;
|
|
padding: 0.75rem 1.5rem;
|
|
border-radius: 8px;
|
|
font-size: 0.95rem;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
.api-controls button:hover {
|
|
background: #991b1b;
|
|
}
|
|
.api-controls button:disabled {
|
|
background: #ccc;
|
|
cursor: not-allowed;
|
|
}
|
|
.tab-button {
|
|
background: #1a1a1a !important;
|
|
border: 1px solid #3f3f3f !important;
|
|
color: #e5e5e5 !important;
|
|
}
|
|
.tab-button:hover {
|
|
background: #2a2a2a !important;
|
|
}
|
|
.tab-button.active {
|
|
border-color: white !important;
|
|
border-width: 2px !important;
|
|
background: #b91c1c !important;
|
|
color: white !important;
|
|
}
|
|
.tab-button.active:hover {
|
|
background: #991b1b !important;
|
|
}
|
|
.epic-status {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
padding: 2rem;
|
|
color: #e5e5e5;
|
|
animation: pulse 2s ease-in-out infinite;
|
|
}
|
|
.epic-status.error {
|
|
color: #fca5a5;
|
|
}
|
|
@keyframes pulse {
|
|
0%,
|
|
100% {
|
|
color: #0a0a0a;
|
|
}
|
|
50% {
|
|
color: #b91c1c;
|
|
}
|
|
}
|
|
.api-controls label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
color: #e5e5e5;
|
|
}
|
|
.output-container {
|
|
position: relative;
|
|
display: none;
|
|
}
|
|
.output-container.visible {
|
|
display: block;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.output-area {
|
|
background: #1a1a1a;
|
|
color: #e5e5e5;
|
|
padding: 1rem;
|
|
padding-top: 2.5rem;
|
|
border-radius: 8px;
|
|
font-family: monospace;
|
|
font-size: 0.85rem;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
user-select: text;
|
|
position: relative;
|
|
}
|
|
.output-area.error {
|
|
color: #fca5a5;
|
|
}
|
|
.output-area.scrollable {
|
|
max-height: 1000px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.copy-button {
|
|
display: none;
|
|
}
|
|
|
|
/* Media attachments */
|
|
.attachments-container {
|
|
margin-top: 1.5rem;
|
|
}
|
|
.attachments-container h3 {
|
|
color: #fca5a5;
|
|
margin: 0 0 1rem 0;
|
|
font-size: 1rem;
|
|
}
|
|
.attachment-item {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.attachment-item img {
|
|
max-width: 100%;
|
|
border-radius: 8px;
|
|
}
|
|
.attachment-item video {
|
|
max-width: 100%;
|
|
border-radius: 8px;
|
|
}
|
|
.attachment-label {
|
|
font-size: 0.85rem;
|
|
color: #a3a3a3;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header style="position: relative">
|
|
<!-- Flux capacitor -->
|
|
<svg
|
|
class="logo"
|
|
viewBox="0 0 48 48"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
>
|
|
<path d="M24 4 L24 20 M24 20 L8 40 M24 20 L40 40" />
|
|
<circle cx="24" cy="4" r="3" fill="currentColor" />
|
|
<circle cx="8" cy="40" r="3" fill="currentColor" />
|
|
<circle cx="40" cy="40" r="3" fill="currentColor" />
|
|
<circle cx="24" cy="20" r="5" fill="none" />
|
|
<circle cx="24" cy="20" r="2" fill="currentColor" />
|
|
</svg>
|
|
<h1>Artery</h1>
|
|
{% if pawprint_url %}<a
|
|
href="{{ pawprint_url }}"
|
|
style="
|
|
position: absolute;
|
|
right: 0;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: rgba(255, 255, 255, 0.7);
|
|
font-size: 0.85rem;
|
|
"
|
|
>← Soleprint</a
|
|
>{% endif %}
|
|
</header>
|
|
<p class="tagline">
|
|
conectores y flujo de datos<!-- All things vital -->
|
|
</p>
|
|
|
|
<section>
|
|
<div class="composition">
|
|
<h3>Pulse</h3>
|
|
<div class="components">
|
|
<div class="component"><h4>Vein</h4></div>
|
|
<div class="component"><h4>Room</h4></div>
|
|
<div class="component"><h4>Depot</h4></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Veins</h2>
|
|
<div class="veins">
|
|
{% for vein in veins %}
|
|
<div
|
|
class="vein{% if vein.status == 'live' or vein.status == 'building' %} active{% else %} disabled{% endif %}{% if loop.first %} selected{% endif %}"
|
|
data-tab="{{ vein.slug }}"
|
|
{% if vein.status == "planned" %}data-disabled="true"{% endif %}
|
|
>
|
|
<h3>{{ vein.title }}</h3>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Jira Tab -->
|
|
<section id="tab-jira" class="tab-content visible">
|
|
<h2>Jira</h2>
|
|
<div class="api-form">
|
|
<label for="jira-email">Jira Email</label>
|
|
<input
|
|
type="text"
|
|
id="jira-email"
|
|
placeholder="your@email.com"
|
|
/>
|
|
<label for="jira-token" style="margin-top: 0.75rem"
|
|
>Jira API Token</label
|
|
>
|
|
<input
|
|
type="text"
|
|
id="jira-token"
|
|
placeholder="Your Jira API token"
|
|
/>
|
|
<div class="api-controls" style="margin-top: 1rem">
|
|
<button id="btn-mine" class="tab-button">My Tickets</button>
|
|
<button id="btn-quick-ticket" class="tab-button">
|
|
Ticket VET-#
|
|
</button>
|
|
<button id="btn-epic" class="tab-button">EPIC #</button>
|
|
<label style="margin-left: auto">
|
|
<input type="checkbox" id="text-mode-list" checked />
|
|
Text output
|
|
</label>
|
|
</div>
|
|
<div
|
|
id="quick-ticket-form"
|
|
style="display: none; margin-top: 0.75rem"
|
|
>
|
|
<label for="quick-ticket-input">VET-</label>
|
|
<input
|
|
type="text"
|
|
id="quick-ticket-input"
|
|
placeholder="123"
|
|
style="
|
|
width: 100px;
|
|
display: inline-block;
|
|
margin-left: 0.5rem;
|
|
"
|
|
/>
|
|
</div>
|
|
<div id="epic-form" style="display: none; margin-top: 0.75rem">
|
|
<label for="epic-input">VET-</label>
|
|
<input
|
|
type="text"
|
|
id="epic-input"
|
|
placeholder="494"
|
|
style="
|
|
width: 100px;
|
|
display: inline-block;
|
|
margin-left: 0.5rem;
|
|
"
|
|
/>
|
|
</div>
|
|
<div
|
|
id="epic-progress"
|
|
style="display: none; margin-top: 1rem; text-align: center"
|
|
>
|
|
<div id="epic-status" class="epic-status">0/0</div>
|
|
</div>
|
|
</div>
|
|
<div id="output-list-container" class="output-container">
|
|
<button class="copy-button" data-target="output-list">
|
|
Copy
|
|
</button>
|
|
<div id="output-list" class="output-area scrollable"></div>
|
|
</div>
|
|
|
|
<div class="api-form" style="margin-top: 1.5rem">
|
|
<label for="ticket-select">Select Ticket</label>
|
|
<select id="ticket-select" disabled>
|
|
<option value="">-- Load tickets first --</option>
|
|
</select>
|
|
<label for="ticket-key" style="margin-top: 1rem"
|
|
>Or enter manually</label
|
|
>
|
|
<input type="text" id="ticket-key" placeholder="e.g. AM-123" />
|
|
<div class="api-controls">
|
|
<button id="btn-ticket">Get Ticket</button>
|
|
<label>
|
|
<input type="checkbox" id="text-mode-ticket" checked />
|
|
Text output
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
id="include-attachments-ticket"
|
|
checked
|
|
/>
|
|
Include attachments
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="output-ticket-container" class="output-container">
|
|
<button class="copy-button" data-target="output-ticket">
|
|
Copy
|
|
</button>
|
|
<div id="output-ticket" class="output-area"></div>
|
|
</div>
|
|
<div id="attachments" class="attachments-container"></div>
|
|
|
|
<h2 style="margin-top: 2rem">Endpoints</h2>
|
|
<ul class="endpoints">
|
|
<li>
|
|
<code>/jira/health</code>
|
|
<span class="desc">Jira connection status</span>
|
|
</li>
|
|
<li>
|
|
<code>/jira/mine</code>
|
|
<span class="desc">My open tickets</span>
|
|
</li>
|
|
<li>
|
|
<code>/jira/backlog?project=X</code>
|
|
<span class="desc">Project backlog</span>
|
|
</li>
|
|
<li>
|
|
<code>/jira/sprint?project=X</code>
|
|
<span class="desc">Current sprint</span>
|
|
</li>
|
|
<li>
|
|
<code>/jira/ticket/{key}</code>
|
|
<span class="desc">Ticket details</span>
|
|
</li>
|
|
</ul>
|
|
<p style="margin-top: 1rem; font-size: 0.85rem; color: #666">
|
|
Add <code>?text=true</code> for LLM-friendly output. Send
|
|
<code>X-API-Key</code> header for programmatic access.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- Slack Tab -->
|
|
<section id="tab-slack" class="tab-content">
|
|
<h2>Slack</h2>
|
|
<div class="api-form">
|
|
<label for="slack-token">Slack Token</label>
|
|
<input
|
|
type="text"
|
|
id="slack-token"
|
|
placeholder="xoxb-... or xoxp-..."
|
|
/>
|
|
<div class="api-controls">
|
|
<button id="btn-channels">List Channels</button>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
id="slack-text-mode-list"
|
|
checked
|
|
/>
|
|
Text output
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="slack-output-list-container" class="output-container">
|
|
<button class="copy-button" data-target="slack-output-list">
|
|
Copy
|
|
</button>
|
|
<div
|
|
id="slack-output-list"
|
|
class="output-area scrollable"
|
|
></div>
|
|
</div>
|
|
|
|
<div class="api-form" style="margin-top: 1.5rem">
|
|
<label for="channel-select">Select Channel</label>
|
|
<select id="channel-select" disabled>
|
|
<option value="">-- Load channels first --</option>
|
|
</select>
|
|
<div class="api-controls">
|
|
<button id="btn-messages">Get Messages</button>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
id="slack-text-mode-messages"
|
|
checked
|
|
/>
|
|
Text output
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" id="slack-include-users" />
|
|
Resolve users
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="slack-output-messages-container" class="output-container">
|
|
<button class="copy-button" data-target="slack-output-messages">
|
|
Copy
|
|
</button>
|
|
<div
|
|
id="slack-output-messages"
|
|
class="output-area scrollable"
|
|
></div>
|
|
</div>
|
|
|
|
<h2 style="margin-top: 2rem">Endpoints</h2>
|
|
<ul class="endpoints">
|
|
<li>
|
|
<code>/slack/health</code>
|
|
<span class="desc">Slack connection status</span>
|
|
</li>
|
|
<li>
|
|
<code>/slack/channels</code>
|
|
<span class="desc">List channels</span>
|
|
</li>
|
|
<li>
|
|
<code>/slack/channels/{id}/messages</code>
|
|
<span class="desc">Channel history</span>
|
|
</li>
|
|
<li>
|
|
<code>/slack/channels/{id}/thread/{ts}</code>
|
|
<span class="desc">Thread replies</span>
|
|
</li>
|
|
<li>
|
|
<code>/slack/users</code>
|
|
<span class="desc">List users</span>
|
|
</li>
|
|
<li>
|
|
<code>/slack/post</code>
|
|
<span class="desc">Post message (POST)</span>
|
|
</li>
|
|
</ul>
|
|
<p style="margin-top: 1rem; font-size: 0.85rem; color: #666">
|
|
Add <code>?text=true</code> for LLM-friendly output. Send
|
|
<code>X-Slack-Token</code> header for programmatic access.
|
|
</p>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="component" style="padding: 1rem">
|
|
<h4>Vein</h4>
|
|
<ul class="endpoints" style="margin: 0">
|
|
{% for vein in veins %}
|
|
<li>
|
|
<code>{{ vein.name }}</code
|
|
><span class="desc"
|
|
>{{ vein.status | capitalize }}</span
|
|
>
|
|
</li>
|
|
{% else %}
|
|
<li><code>--</code><span class="desc">Pending</span></li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="component" style="padding: 1rem">
|
|
<h4>Room</h4>
|
|
<ul class="endpoints" style="margin: 0">
|
|
{% for room in rooms %}
|
|
<li>
|
|
<code>{{ room.name }}</code
|
|
><span class="desc"
|
|
>{{ room.status | capitalize }}</span
|
|
>
|
|
</li>
|
|
{% else %}
|
|
<li><code>--</code><span class="desc">Pending</span></li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="component" style="padding: 1rem">
|
|
<h4>Depot</h4>
|
|
<ul class="endpoints" style="margin: 0">
|
|
{% for depot in depots %}
|
|
<li>
|
|
<code>{{ depot.name }}</code
|
|
><span class="desc"
|
|
>{{ depot.status | capitalize }}</span
|
|
>
|
|
</li>
|
|
{% else %}
|
|
<li><code>--</code><span class="desc">Pending</span></li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Shunts</h2>
|
|
<ul class="endpoints">
|
|
{% for shunt in shunts %}
|
|
<li>
|
|
<code>{{ shunt.name }}</code
|
|
><span class="desc">{{ shunt.status | capitalize }}</span>
|
|
</li>
|
|
{% else %}
|
|
<li><code>--</code><span class="desc">Pending</span></li>
|
|
{% endfor %}
|
|
</ul>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Pulses</h2>
|
|
<ul class="endpoints">
|
|
{% for pulse in pulses %}
|
|
<li>
|
|
<code>{{ pulse.name }}</code
|
|
><span class="desc">{{ pulse.status | capitalize }}</span>
|
|
</li>
|
|
{% else %}
|
|
<li><code>--</code><span class="desc">Pending</span></li>
|
|
{% endfor %}
|
|
</ul>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Plexuses</h2>
|
|
<ul class="endpoints">
|
|
{% for plexus in plexuses %}
|
|
<li>
|
|
<code>{{ plexus.name }}</code
|
|
><span class="desc">{{ plexus.status | capitalize }}</span>
|
|
</li>
|
|
{% else %}
|
|
<li><code>whatsapp</code><span class="desc">Planned</span></li>
|
|
{% endfor %}
|
|
</ul>
|
|
</section>
|
|
|
|
<!-- Placeholder tabs -->
|
|
<section id="tab-google" class="tab-content">
|
|
<h2>Google</h2>
|
|
<p>Google connector. Planned.</p>
|
|
</section>
|
|
|
|
<section id="tab-maps" class="tab-content">
|
|
<h2>Maps</h2>
|
|
<p>Maps connector. Planned.</p>
|
|
</section>
|
|
|
|
<section id="tab-cash" class="tab-content">
|
|
<h2>Cash</h2>
|
|
<p>Cash connector. Planned.</p>
|
|
</section>
|
|
|
|
<section id="tab-whatsapp" class="tab-content">
|
|
<h2>WhatsApp</h2>
|
|
<p>WhatsApp connector. In development.</p>
|
|
</section>
|
|
|
|
<section id="tab-vnc" class="tab-content">
|
|
<h2>VNC</h2>
|
|
<p>VNC connector. Planned.</p>
|
|
</section>
|
|
|
|
<section id="tab-ia" class="tab-content">
|
|
<h2>IA</h2>
|
|
<p>IA connector. Planned.</p>
|
|
</section>
|
|
|
|
<footer>
|
|
{% if pawprint_url %}<a href="{{ pawprint_url }}">← Soleprint</a>{%
|
|
else %}<span class="disabled">← Soleprint</span>{% endif %}
|
|
</footer>
|
|
|
|
<script>
|
|
// Tab switching
|
|
document.querySelectorAll(".vein[data-tab]").forEach((vein) => {
|
|
vein.addEventListener("click", () => {
|
|
// Don't switch to disabled veins
|
|
if (vein.dataset.disabled === "true") return;
|
|
|
|
document
|
|
.querySelectorAll(".vein")
|
|
.forEach((v) => v.classList.remove("selected"));
|
|
vein.classList.add("selected");
|
|
|
|
const tabId = vein.dataset.tab;
|
|
document
|
|
.querySelectorAll(".tab-content")
|
|
.forEach((t) => t.classList.remove("visible"));
|
|
document
|
|
.getElementById("tab-" + tabId)
|
|
?.classList.add("visible");
|
|
});
|
|
});
|
|
|
|
function showError(outputEl, msg) {
|
|
outputEl.textContent = "Error: " + msg;
|
|
outputEl.classList.add("visible", "error");
|
|
}
|
|
|
|
async function renderAttachments(data) {
|
|
const container = document.getElementById("attachments");
|
|
container.innerHTML = "";
|
|
|
|
if (!data.attachments || data.attachments.length === 0) return;
|
|
|
|
// List all attachment URLs (direct Jira links)
|
|
let html =
|
|
'<h3>Attachment Links (download from Jira)</h3><ul style="margin:0 0 1.5rem 0;padding-left:1.5rem;">';
|
|
data.attachments.forEach((att) => {
|
|
html += `<li style="margin:0.25rem 0;"><a href="${att.url}" target="_blank" style="color:#fca5a5;">${att.filename}</a> <span style="color:#666;">(${att.mimetype})</span></li>`;
|
|
});
|
|
html += "</ul>";
|
|
container.innerHTML = html;
|
|
|
|
const mediaAttachments = data.attachments.filter(
|
|
(a) =>
|
|
a.mimetype.startsWith("image/") ||
|
|
a.mimetype.startsWith("video/"),
|
|
);
|
|
if (mediaAttachments.length === 0) return;
|
|
|
|
container.innerHTML += "<h3>Preview</h3>";
|
|
|
|
for (const att of mediaAttachments) {
|
|
const div = document.createElement("div");
|
|
div.className = "attachment-item";
|
|
|
|
const label = document.createElement("div");
|
|
label.className = "attachment-label";
|
|
label.textContent = att.filename + " (loading...)";
|
|
div.appendChild(label);
|
|
container.appendChild(div);
|
|
|
|
// Fetch with header auth, convert to blob URL
|
|
try {
|
|
const res = await fetch(`/jira/attachment/${att.id}`, {
|
|
headers: getAuthHeaders(),
|
|
});
|
|
if (!res.ok) throw new Error("Failed to load");
|
|
|
|
const blob = await res.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
|
|
label.textContent = att.filename;
|
|
|
|
if (att.mimetype.startsWith("image/")) {
|
|
const img = document.createElement("img");
|
|
img.src = blobUrl;
|
|
img.alt = att.filename;
|
|
div.appendChild(img);
|
|
} else if (att.mimetype.startsWith("video/")) {
|
|
const video = document.createElement("video");
|
|
video.src = blobUrl;
|
|
video.controls = true;
|
|
div.appendChild(video);
|
|
}
|
|
} catch (e) {
|
|
label.textContent = att.filename + " (failed to load)";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mutual exclusivity: dropdown vs manual input
|
|
const ticketSelect = document.getElementById("ticket-select");
|
|
const ticketKey = document.getElementById("ticket-key");
|
|
|
|
ticketSelect.addEventListener("change", () => {
|
|
if (ticketSelect.value) {
|
|
ticketKey.disabled = true;
|
|
ticketKey.value = "";
|
|
} else {
|
|
ticketKey.disabled = false;
|
|
}
|
|
});
|
|
|
|
ticketKey.addEventListener("input", () => {
|
|
if (ticketKey.value.trim()) {
|
|
ticketSelect.disabled = true;
|
|
ticketSelect.value = "";
|
|
} else {
|
|
ticketSelect.disabled = ticketSelect.options.length <= 1;
|
|
}
|
|
});
|
|
|
|
// Get credentials helper
|
|
function getCredentials() {
|
|
const email = document
|
|
.getElementById("jira-email")
|
|
.value.trim();
|
|
const token = document
|
|
.getElementById("jira-token")
|
|
.value.trim();
|
|
return { email, token };
|
|
}
|
|
|
|
function getAuthHeaders() {
|
|
const { email, token } = getCredentials();
|
|
return {
|
|
"X-Jira-Email": email,
|
|
"X-Jira-Token": token,
|
|
};
|
|
}
|
|
|
|
// Output container helpers
|
|
function showOutput(outputId) {
|
|
const output = document.getElementById(outputId);
|
|
const container = output.parentElement;
|
|
container.classList.add("visible");
|
|
}
|
|
|
|
function hideOutput(outputId) {
|
|
const output = document.getElementById(outputId);
|
|
const container = output.parentElement;
|
|
container.classList.remove("visible");
|
|
}
|
|
|
|
function clearOutput(outputId) {
|
|
const output = document.getElementById(outputId);
|
|
output.textContent = "";
|
|
output.classList.remove("error");
|
|
}
|
|
|
|
// Copy button functionality
|
|
document.querySelectorAll(".copy-button").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const targetId = btn.getAttribute("data-target");
|
|
const target = document.getElementById(targetId);
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(target.textContent);
|
|
btn.textContent = "Copied!";
|
|
btn.classList.add("copied");
|
|
setTimeout(() => {
|
|
btn.textContent = "Copy";
|
|
btn.classList.remove("copied");
|
|
}, 2000);
|
|
} catch (e) {
|
|
btn.textContent = "Failed";
|
|
setTimeout(() => {
|
|
btn.textContent = "Copy";
|
|
}, 2000);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Triple-click to select all in output areas
|
|
document.querySelectorAll(".output-area").forEach((area) => {
|
|
area.addEventListener("click", (e) => {
|
|
if (e.detail === 3) {
|
|
// Triple click
|
|
const selection = window.getSelection();
|
|
const range = document.createRange();
|
|
range.selectNodeContents(area);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Tab system for buttons
|
|
const btnMine = document.getElementById("btn-mine");
|
|
const btnQuickTicket = document.getElementById("btn-quick-ticket");
|
|
const btnEpic = document.getElementById("btn-epic");
|
|
const quickTicketForm =
|
|
document.getElementById("quick-ticket-form");
|
|
const quickTicketInput =
|
|
document.getElementById("quick-ticket-input");
|
|
const epicForm = document.getElementById("epic-form");
|
|
const epicInput = document.getElementById("epic-input");
|
|
const epicProgress = document.getElementById("epic-progress");
|
|
const epicStatus = document.getElementById("epic-status");
|
|
|
|
function setActiveTab(button) {
|
|
// Remove active from all tab buttons
|
|
document
|
|
.querySelectorAll(".tab-button")
|
|
.forEach((btn) => btn.classList.remove("active"));
|
|
// Set active on clicked button
|
|
button.classList.add("active");
|
|
|
|
// Hide all tab-specific forms
|
|
quickTicketForm.style.display = "none";
|
|
epicForm.style.display = "none";
|
|
epicProgress.style.display = "none";
|
|
|
|
// Show form for this tab if needed
|
|
if (button === btnQuickTicket) {
|
|
quickTicketForm.style.display = "block";
|
|
quickTicketInput.focus();
|
|
} else if (button === btnEpic) {
|
|
epicForm.style.display = "block";
|
|
epicInput.focus();
|
|
}
|
|
}
|
|
|
|
// My Tickets
|
|
btnMine.addEventListener("click", async () => {
|
|
if (!btnMine.classList.contains("active")) {
|
|
// First click: activate tab
|
|
setActiveTab(btnMine);
|
|
return;
|
|
}
|
|
|
|
// Already active: execute action
|
|
const textMode =
|
|
document.getElementById("text-mode-list").checked;
|
|
const output = document.getElementById("output-list");
|
|
|
|
clearOutput("output-list");
|
|
output.textContent = "Loading...";
|
|
showOutput("output-list");
|
|
|
|
try {
|
|
// Always fetch JSON to populate dropdown
|
|
const res = await fetch("/jira/mine", {
|
|
headers: getAuthHeaders(),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || res.statusText);
|
|
}
|
|
|
|
const json = await res.json();
|
|
|
|
// Populate dropdown
|
|
ticketSelect.innerHTML =
|
|
'<option value="">-- Select a ticket --</option>';
|
|
json.tickets.forEach((t) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = t.key;
|
|
opt.textContent = `${t.key}: ${t.summary.substring(0, 60)}${t.summary.length > 60 ? "..." : ""}`;
|
|
ticketSelect.appendChild(opt);
|
|
});
|
|
ticketSelect.disabled = false;
|
|
ticketKey.disabled = false;
|
|
|
|
// Display output
|
|
if (textMode) {
|
|
// Fetch text version for display
|
|
const textRes = await fetch("/jira/mine?text=true", {
|
|
headers: getAuthHeaders(),
|
|
});
|
|
output.textContent = await textRes.text();
|
|
} else {
|
|
output.textContent = JSON.stringify(json, null, 2);
|
|
}
|
|
output.classList.remove("error");
|
|
} catch (e) {
|
|
showError(output, e.message);
|
|
}
|
|
});
|
|
|
|
// Quick Ticket (VET-#)
|
|
btnQuickTicket.addEventListener("click", async () => {
|
|
if (!btnQuickTicket.classList.contains("active")) {
|
|
// First click: activate tab and show form
|
|
setActiveTab(btnQuickTicket);
|
|
return;
|
|
}
|
|
|
|
// Already active: execute action
|
|
const ticketNum = quickTicketInput.value.trim();
|
|
if (!ticketNum) {
|
|
alert("Please enter a ticket number");
|
|
return;
|
|
}
|
|
|
|
const ticketKey = "VET-" + ticketNum;
|
|
const textMode =
|
|
document.getElementById("text-mode-list").checked;
|
|
const output = document.getElementById("output-list");
|
|
|
|
clearOutput("output-list");
|
|
output.textContent = "Loading...";
|
|
showOutput("output-list");
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (textMode) params.set("text", "true");
|
|
|
|
const res = await fetch(
|
|
`/jira/ticket/${ticketKey}?${params}`,
|
|
{
|
|
headers: getAuthHeaders(),
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || res.statusText);
|
|
}
|
|
|
|
if (textMode) {
|
|
output.textContent = await res.text();
|
|
} else {
|
|
output.textContent = JSON.stringify(
|
|
await res.json(),
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
output.classList.remove("error");
|
|
} catch (e) {
|
|
showError(output, e.message);
|
|
}
|
|
});
|
|
|
|
// Allow Enter key to submit
|
|
quickTicketInput.addEventListener("keypress", (e) => {
|
|
if (e.key === "Enter") {
|
|
btnQuickTicket.click();
|
|
}
|
|
});
|
|
|
|
// EPIC Processing
|
|
btnEpic.addEventListener("click", async () => {
|
|
if (!btnEpic.classList.contains("active")) {
|
|
// First click: activate tab and show form
|
|
setActiveTab(btnEpic);
|
|
return;
|
|
}
|
|
|
|
// Already active: execute action
|
|
const epicNum = epicInput.value.trim();
|
|
if (!epicNum) {
|
|
alert("Please enter an epic number");
|
|
return;
|
|
}
|
|
|
|
const epicKey = "VET-" + epicNum;
|
|
const output = document.getElementById("output-list");
|
|
|
|
// Don't show progress until we get first response
|
|
clearOutput("output-list");
|
|
hideOutput("output-list");
|
|
|
|
console.log("Starting EPIC fetch for:", epicKey);
|
|
console.log("Auth headers:", getAuthHeaders());
|
|
|
|
try {
|
|
console.log("Making fetch request...");
|
|
const response = await fetch(
|
|
`/jira/epic/${epicKey}/process`,
|
|
{
|
|
method: "POST",
|
|
headers: getAuthHeaders(),
|
|
},
|
|
);
|
|
|
|
console.log(
|
|
"Fetch response:",
|
|
response.status,
|
|
response.statusText,
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to process epic");
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
console.log("Starting to read stream...");
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
console.log(
|
|
"Stream read:",
|
|
done ? "DONE" : `${value.length} bytes`,
|
|
);
|
|
if (done) break;
|
|
|
|
const chunk = decoder.decode(value);
|
|
console.log("Decoded chunk:", chunk);
|
|
const lines = chunk.split("\n").filter((l) => l.trim());
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
console.log("Parsing line:", line);
|
|
const data = JSON.parse(line);
|
|
console.log("Parsed data:", data);
|
|
|
|
// Show progress indicator on first data
|
|
if (epicProgress.style.display === "none") {
|
|
epicProgress.style.display = "block";
|
|
epicStatus.textContent = "0/0";
|
|
epicStatus.classList.remove("error");
|
|
}
|
|
|
|
if (
|
|
data.status === "processing" ||
|
|
data.status === "complete"
|
|
) {
|
|
epicStatus.textContent = `${data.completed}/${data.total}`;
|
|
epicStatus.classList.remove("error");
|
|
} else if (data.status === "error") {
|
|
epicStatus.textContent = "Error";
|
|
epicStatus.classList.add("error");
|
|
// Show raw error with all details
|
|
const errorDetails = JSON.stringify(
|
|
data,
|
|
null,
|
|
2,
|
|
);
|
|
output.textContent = `Error occurred:\n\n${errorDetails}`;
|
|
output.classList.add("error");
|
|
showOutput("output-list");
|
|
} else if (data.status === "fetching_epic") {
|
|
epicStatus.textContent = "Fetching...";
|
|
}
|
|
|
|
if (data.status === "complete") {
|
|
setTimeout(() => {
|
|
if (data.text) {
|
|
output.textContent = data.text;
|
|
} else {
|
|
output.textContent = `Epic processed successfully!\nFiles saved to: ${data.path}`;
|
|
}
|
|
showOutput("output-list");
|
|
}, 500);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to parse line:", line, e);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
epicStatus.textContent = "Error";
|
|
epicStatus.classList.add("error");
|
|
output.textContent = `Request failed:\n\nError: ${e.message}\nStack: ${e.stack || "N/A"}`;
|
|
output.classList.add("error");
|
|
showOutput("output-list");
|
|
}
|
|
});
|
|
|
|
epicInput.addEventListener("keypress", (e) => {
|
|
if (e.key === "Enter") {
|
|
btnEpic.click();
|
|
}
|
|
});
|
|
|
|
// Get Ticket
|
|
document
|
|
.getElementById("btn-ticket")
|
|
.addEventListener("click", async () => {
|
|
const selectedTicket =
|
|
ticketSelect.value ||
|
|
document.getElementById("ticket-key").value.trim();
|
|
const includeAttachments = document.getElementById(
|
|
"include-attachments-ticket",
|
|
).checked;
|
|
const textMode =
|
|
document.getElementById("text-mode-ticket").checked;
|
|
const output = document.getElementById("output-ticket");
|
|
const attachments = document.getElementById("attachments");
|
|
|
|
clearOutput("output-ticket");
|
|
attachments.innerHTML = "";
|
|
|
|
if (!selectedTicket) {
|
|
showError(output, "Select a ticket or enter a key");
|
|
return;
|
|
}
|
|
|
|
output.textContent = "Loading...";
|
|
showOutput("output-ticket");
|
|
|
|
try {
|
|
// Always fetch JSON when including attachments (to render media)
|
|
if (includeAttachments) {
|
|
const jsonRes = await fetch(
|
|
`/jira/ticket/${selectedTicket}?include_attachments=true`,
|
|
{
|
|
headers: getAuthHeaders(),
|
|
},
|
|
);
|
|
|
|
if (!jsonRes.ok) {
|
|
const err = await jsonRes.json();
|
|
throw new Error(
|
|
err.detail || jsonRes.statusText,
|
|
);
|
|
}
|
|
|
|
const json = await jsonRes.json();
|
|
|
|
// Display text or JSON based on mode
|
|
if (textMode) {
|
|
const textRes = await fetch(
|
|
`/jira/ticket/${selectedTicket}?text=true`,
|
|
{
|
|
headers: getAuthHeaders(),
|
|
},
|
|
);
|
|
output.textContent = await textRes.text();
|
|
} else {
|
|
output.textContent = JSON.stringify(
|
|
json,
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
// Render attachments
|
|
renderAttachments(json);
|
|
} else {
|
|
const params = new URLSearchParams();
|
|
if (textMode) params.set("text", "true");
|
|
|
|
const res = await fetch(
|
|
`/jira/ticket/${selectedTicket}?${params}`,
|
|
{
|
|
headers: getAuthHeaders(),
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || res.statusText);
|
|
}
|
|
|
|
if (textMode) {
|
|
output.textContent = await res.text();
|
|
} else {
|
|
output.textContent = JSON.stringify(
|
|
await res.json(),
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
}
|
|
output.classList.remove("error");
|
|
} catch (e) {
|
|
showError(output, e.message);
|
|
}
|
|
});
|
|
|
|
// ===== SLACK =====
|
|
const channelSelect = document.getElementById("channel-select");
|
|
|
|
function getSlackToken() {
|
|
return document.getElementById("slack-token").value.trim();
|
|
}
|
|
|
|
function getSlackAuthHeaders() {
|
|
return { "X-Slack-Token": getSlackToken() };
|
|
}
|
|
|
|
// List Channels
|
|
document
|
|
.getElementById("btn-channels")
|
|
.addEventListener("click", async () => {
|
|
const textMode = document.getElementById(
|
|
"slack-text-mode-list",
|
|
).checked;
|
|
const output = document.getElementById("slack-output-list");
|
|
|
|
clearOutput("slack-output-list");
|
|
output.textContent = "Loading...";
|
|
showOutput("slack-output-list");
|
|
|
|
try {
|
|
// Always fetch JSON to populate dropdown
|
|
const res = await fetch("/slack/channels", {
|
|
headers: getSlackAuthHeaders(),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || res.statusText);
|
|
}
|
|
|
|
const json = await res.json();
|
|
|
|
// Populate dropdown
|
|
channelSelect.innerHTML =
|
|
'<option value="">-- Select a channel --</option>';
|
|
json.channels.forEach((ch) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = ch.id;
|
|
const prefix = ch.is_private ? "🔒 " : "#";
|
|
opt.textContent = `${prefix}${ch.name}`;
|
|
channelSelect.appendChild(opt);
|
|
});
|
|
channelSelect.disabled = false;
|
|
|
|
// Display output
|
|
if (textMode) {
|
|
const textRes = await fetch(
|
|
"/slack/channels?text=true",
|
|
{
|
|
headers: getSlackAuthHeaders(),
|
|
},
|
|
);
|
|
output.textContent = await textRes.text();
|
|
} else {
|
|
output.textContent = JSON.stringify(json, null, 2);
|
|
}
|
|
output.classList.remove("error");
|
|
} catch (e) {
|
|
showError(output, e.message);
|
|
}
|
|
});
|
|
|
|
// Get Messages
|
|
document
|
|
.getElementById("btn-messages")
|
|
.addEventListener("click", async () => {
|
|
const selectedChannel = channelSelect.value;
|
|
const textMode = document.getElementById(
|
|
"slack-text-mode-messages",
|
|
).checked;
|
|
const includeUsers = document.getElementById(
|
|
"slack-include-users",
|
|
).checked;
|
|
const output = document.getElementById(
|
|
"slack-output-messages",
|
|
);
|
|
|
|
clearOutput("slack-output-messages");
|
|
|
|
if (!selectedChannel) {
|
|
showError(output, "Select a channel first");
|
|
return;
|
|
}
|
|
|
|
output.textContent = "Loading...";
|
|
showOutput("slack-output-messages");
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (textMode) params.set("text", "true");
|
|
if (includeUsers) params.set("include_users", "true");
|
|
|
|
const res = await fetch(
|
|
`/slack/channels/${selectedChannel}/messages?${params}`,
|
|
{
|
|
headers: getSlackAuthHeaders(),
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || res.statusText);
|
|
}
|
|
|
|
if (textMode) {
|
|
output.textContent = await res.text();
|
|
} else {
|
|
output.textContent = JSON.stringify(
|
|
await res.json(),
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
output.classList.remove("error");
|
|
} catch (e) {
|
|
showError(output, e.message);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|