497 lines
15 KiB
HTML
497 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<!--
|
|
The jira vein's ad-hoc interface — the `ui/` slot veins/__init__.py has
|
|
declared since the beginning and no vein had filled.
|
|
|
|
It does the vein page job, which rig-ui states twice in its own comments after
|
|
rebuilding this look by hand: name what the thing exposes, and show what comes
|
|
back. Tool chrome and output are styled apart on purpose — that separation is
|
|
what tells you whether you are reading the tool or its result.
|
|
|
|
THREE RULES, all consequences of "a vein serves this on its own port, and it
|
|
must also open from a double-clicked file":
|
|
|
|
no /theme.css an absolute path assumes a server at the root
|
|
no build step no npm, no bundler, no Vue
|
|
no webfont a blocked stylesheet is a stall, not a fallback
|
|
|
|
So the theme and the parts are BAKED IN, between markers, by
|
|
common/theme/bake.py. Everything outside those markers is this page's, to edit
|
|
freely. Run `make theme bake` after changing a class; `make theme check` says
|
|
when a copy has gone stale.
|
|
|
|
The parts are chosen by what the markup uses — panel and split here, nothing
|
|
else. That is the whole point: this page carries no table, no log view, no
|
|
uplot, no vue-flow, because it uses none of them.
|
|
-->
|
|
<html lang="en" data-theme="soleprint">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>jira — vein</title>
|
|
<!-- theme:here -->
|
|
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
|
<style>
|
|
:root {
|
|
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
|
|
--font-size-base: 13px;
|
|
--font-size-sm: 11px;
|
|
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
|
|
--muted: #8888a0;
|
|
--panel-border: 1px solid #2e2e38;
|
|
--panel-header-height: 36px;
|
|
--panel-radius: 6px;
|
|
--space-1: 4px;
|
|
--space-2: 8px;
|
|
--space-3: 12px;
|
|
--space-4: 16px;
|
|
--status-error: #f06565;
|
|
--status-idle: #555568;
|
|
--status-live: #3ecf8e;
|
|
--status-processing: #4f9cf9;
|
|
--surface-0: #0d0d0f;
|
|
--surface-1: #16161a;
|
|
--surface-2: #1e1e24;
|
|
--surface-3: #2e2e38;
|
|
--text-dim: #555568;
|
|
--text-primary: #e8e8f0;
|
|
--text-secondary: #8888a0;
|
|
}
|
|
</style>
|
|
<!-- /theme:baked-defaults -->
|
|
<!-- theme:parts — generated by common/theme/bake.py; do not edit -->
|
|
<style>
|
|
/* part: base — common/theme/parts/base.css */
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
}
|
|
|
|
body {
|
|
background: var(--surface-0);
|
|
color: var(--text-primary);
|
|
font-family: var(--font-ui);
|
|
font-size: var(--font-size-base);
|
|
}
|
|
|
|
/* Opt in on <html>, <body> and the top element when the page is an app shell
|
|
* that should fill the viewport. Left off, the page scrolls like a document —
|
|
* which is what most ad-hoc vein pages actually want. */
|
|
.fills {
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
button {
|
|
font-family: var(--font-ui);
|
|
font-size: var(--font-size-base);
|
|
color: var(--text-primary);
|
|
background: var(--surface-2);
|
|
border: var(--panel-border);
|
|
border-radius: var(--panel-radius);
|
|
padding: var(--space-1) var(--space-3);
|
|
cursor: pointer;
|
|
}
|
|
|
|
button:hover:not(:disabled) {
|
|
background: var(--surface-3);
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
|
|
input,
|
|
select,
|
|
textarea {
|
|
font-family: var(--font-ui);
|
|
font-size: var(--font-size-base);
|
|
color: var(--text-primary);
|
|
background: var(--surface-0);
|
|
border: var(--panel-border);
|
|
border-radius: 4px;
|
|
padding: var(--space-1) var(--space-2);
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--surface-3);
|
|
border-radius: 5px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
/* part: panel — common/theme/parts/panel.css */
|
|
.panel {
|
|
position: relative;
|
|
background: var(--surface-1);
|
|
border: var(--panel-border);
|
|
border-radius: var(--panel-radius);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
height: var(--panel-header-height);
|
|
padding: 0 var(--space-3);
|
|
background: var(--surface-2);
|
|
border-bottom: var(--panel-border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel-title {
|
|
font-family: var(--font-ui);
|
|
font-size: var(--font-size-sm);
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.panel-actions {
|
|
margin-left: auto;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.panel-status {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
background: var(--status-idle);
|
|
}
|
|
.panel-status.idle { background: var(--status-idle); }
|
|
.panel-status.live { background: var(--status-live); }
|
|
.panel-status.processing { background: var(--status-processing); }
|
|
.panel-status.error { background: var(--status-error); }
|
|
|
|
.panel-body {
|
|
flex: 1;
|
|
overflow: auto;
|
|
padding: var(--space-2);
|
|
min-height: 0;
|
|
}
|
|
/* part: split — common/theme/parts/split.css */
|
|
.split-pane {
|
|
display: flex;
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.split-pane.horizontal {
|
|
flex-direction: row;
|
|
}
|
|
|
|
.split-pane.vertical {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.split-first,
|
|
.split-second {
|
|
min-height: 0;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
flex: 1;
|
|
}
|
|
|
|
/* Children fill their pane. */
|
|
.split-first > *,
|
|
.split-second > * {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.split-divider {
|
|
flex-shrink: 0;
|
|
background: transparent;
|
|
transition: background 0.15s;
|
|
touch-action: none;
|
|
z-index: 10;
|
|
}
|
|
|
|
.split-divider:hover,
|
|
.split-divider.dragging {
|
|
background: var(--text-dim);
|
|
}
|
|
|
|
.split-pane.horizontal > .split-divider {
|
|
width: 4px;
|
|
cursor: col-resize;
|
|
margin: 0 -2px;
|
|
}
|
|
|
|
.split-pane.vertical > .split-divider {
|
|
height: 4px;
|
|
cursor: row-resize;
|
|
margin: -2px 0;
|
|
}
|
|
</style>
|
|
<script>
|
|
/* part: split — common/theme/parts/split.js */
|
|
(function () {
|
|
'use strict'
|
|
|
|
function setup(root) {
|
|
var divider = root.querySelector(':scope > .split-divider')
|
|
if (!divider) return // no divider: a fixed split, deliberately
|
|
|
|
var first = root.querySelector(':scope > .split-first')
|
|
var second = root.querySelector(':scope > .split-second')
|
|
if (!first || !second) return
|
|
|
|
var horizontal = !root.classList.contains('vertical')
|
|
var mode = root.dataset.mode === 'px' ? 'px' : 'ratio'
|
|
var anchor = root.dataset.anchor === 'second' ? 'second' : 'first'
|
|
var size = parseFloat(root.dataset.size)
|
|
if (isNaN(size)) size = 1
|
|
var min = parseFloat(root.dataset.min)
|
|
if (isNaN(min)) min = mode === 'px' ? 0 : 0.1
|
|
var max = parseFloat(root.dataset.max)
|
|
if (isNaN(max)) max = mode === 'px' ? Infinity : 10
|
|
|
|
var sized = anchor === 'second' ? second : first
|
|
var flexed = anchor === 'second' ? first : second
|
|
var dragging = false
|
|
var startPos = 0
|
|
|
|
function apply() {
|
|
flexed.style.flex = '1'
|
|
if (mode === 'px') {
|
|
sized.style.flex = '0 0 auto'
|
|
sized.style[horizontal ? 'width' : 'height'] = size + 'px'
|
|
} else {
|
|
sized.style.flex = String(size)
|
|
}
|
|
}
|
|
|
|
divider.addEventListener('pointerdown', function (e) {
|
|
dragging = true
|
|
startPos = horizontal ? e.clientX : e.clientY
|
|
divider.classList.add('dragging')
|
|
divider.setPointerCapture(e.pointerId)
|
|
})
|
|
|
|
divider.addEventListener('pointermove', function (e) {
|
|
if (!dragging) return
|
|
var pos = horizontal ? e.clientX : e.clientY
|
|
var delta = pos - startPos
|
|
startPos = pos
|
|
|
|
// Dragging right/down grows the first pane. When the SECOND pane is the
|
|
// anchored one, that same gesture must shrink it, so invert.
|
|
if (anchor === 'second') delta = -delta
|
|
|
|
// Ratio mode is unitless, so pixels are scaled into it. The two constants
|
|
// are the SFC's, kept rather than re-derived: they are what the existing
|
|
// panes were tuned against, and vertical drags cover less travel.
|
|
var step = mode === 'px' ? delta : delta * (horizontal ? 0.01 : 0.02)
|
|
size = Math.max(min, Math.min(max, size + step))
|
|
apply()
|
|
})
|
|
|
|
function end(e) {
|
|
if (!dragging) return
|
|
dragging = false
|
|
divider.classList.remove('dragging')
|
|
if (e && e.pointerId !== undefined && divider.hasPointerCapture(e.pointerId)) {
|
|
divider.releasePointerCapture(e.pointerId)
|
|
}
|
|
}
|
|
divider.addEventListener('pointerup', end)
|
|
divider.addEventListener('pointercancel', end)
|
|
|
|
apply()
|
|
}
|
|
|
|
function start() {
|
|
var panes = document.querySelectorAll('[data-split]')
|
|
for (var i = 0; i < panes.length; i++) setup(panes[i])
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', start)
|
|
} else {
|
|
start()
|
|
}
|
|
})()
|
|
</script>
|
|
<!-- /theme:parts -->
|
|
|
|
<style>
|
|
/* This page's own, and only its own. */
|
|
body { padding: var(--space-4); }
|
|
.page { display: flex; flex-direction: column; gap: var(--space-3); height: calc(100vh - 2 * var(--space-4)); }
|
|
|
|
header { display: flex; align-items: baseline; gap: var(--space-3); flex-wrap: wrap; }
|
|
h1 { margin: 0; font-size: 20px; font-family: var(--font-ui); }
|
|
.sub { color: var(--muted); font-family: var(--font-mono); font-size: 12px; }
|
|
|
|
.split-pane { border: var(--panel-border); border-radius: var(--panel-radius); }
|
|
|
|
.routes { display: flex; flex-direction: column; gap: 2px; }
|
|
.route {
|
|
display: flex; align-items: center; gap: var(--space-2);
|
|
padding: 4px var(--space-2); border-radius: var(--panel-radius);
|
|
font-family: var(--font-mono); font-size: 12px;
|
|
background: none; border: 0; width: 100%; text-align: left;
|
|
}
|
|
.route:hover:not(:disabled) { background: var(--surface-2); }
|
|
.route.on { background: var(--surface-3); }
|
|
.verb {
|
|
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 3px;
|
|
border: 1px solid currentColor; flex-shrink: 0;
|
|
}
|
|
.verb.GET { color: var(--status-processing); }
|
|
.verb.POST { color: var(--status-live); }
|
|
|
|
.controls { display: flex; gap: var(--space-2); margin-bottom: var(--space-2); }
|
|
.controls input { flex: 1; font-family: var(--font-mono); font-size: 12px; }
|
|
|
|
/* OUTPUT — a raised block, monospace, selectable. It is a payload, not
|
|
furniture, and should not read like the tool that produced it. */
|
|
.out {
|
|
margin: 0; padding: var(--space-3);
|
|
background: var(--surface-0); border: var(--panel-border);
|
|
border-radius: var(--panel-radius);
|
|
font-family: var(--font-mono); font-size: 12px; line-height: 1.5;
|
|
white-space: pre-wrap; word-break: break-word;
|
|
user-select: text; min-height: 100%;
|
|
}
|
|
.out.err { color: var(--status-error); }
|
|
.hint { color: var(--text-dim); }
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="page">
|
|
<header>
|
|
<h1>jira</h1>
|
|
<span class="sub">vein · stateless API connector</span>
|
|
<span class="sub hint" id="base"></span>
|
|
</header>
|
|
|
|
<div class="split-pane horizontal" data-split data-size="1" data-min="0.4" data-max="3" style="flex:1; min-height:0;">
|
|
<div class="split-first">
|
|
<div class="panel">
|
|
<div class="panel-header">
|
|
<span class="panel-title">Exposes</span>
|
|
<span class="panel-actions"><span class="sub" id="count"></span></span>
|
|
</div>
|
|
<div class="panel-body">
|
|
<div class="routes" id="routes"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="split-divider"></div>
|
|
|
|
<div class="split-second">
|
|
<div class="panel">
|
|
<div class="panel-header">
|
|
<span class="panel-title">Returns</span>
|
|
<span class="panel-actions"><button id="run" disabled>send</button></span>
|
|
<span class="panel-status idle" id="dot"></span>
|
|
</div>
|
|
<div class="panel-body">
|
|
<div class="controls">
|
|
<input id="arg" placeholder="pick a route" disabled>
|
|
</div>
|
|
<pre class="out hint" id="out">Pick a route on the left, then send.
|
|
|
|
Opened from a file rather than served? Nothing will answer — the routes are
|
|
still the contract, which is half of what this page is for.</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
/* The vein's surface, as the page understands it. Kept here rather than fetched
|
|
so the page still says what the vein exposes when nothing is serving it. */
|
|
const ROUTES = [
|
|
{ verb: 'GET', path: '/health', arg: null, hint: 'connection check' },
|
|
{ verb: 'GET', path: '/mine', arg: null, hint: 'tickets assigned to you' },
|
|
{ verb: 'GET', path: '/backlog', arg: null, hint: 'the backlog' },
|
|
{ verb: 'GET', path: '/sprint', arg: null, hint: 'the current sprint' },
|
|
{ verb: 'GET', path: '/ticket/{key}', arg: 'key', hint: 'one ticket, e.g. PROJ-123' },
|
|
{ verb: 'POST', path: '/search', arg: 'jql', hint: 'a JQL query' },
|
|
{ verb: 'GET', path: '/epic/{key}/status', arg: 'key', hint: 'epic processing status' },
|
|
];
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
let active = null;
|
|
|
|
$('base').textContent = location.protocol === 'file:' ? 'not served — file://' : location.origin;
|
|
$('count').textContent = ROUTES.length + ' routes';
|
|
|
|
ROUTES.forEach((r, i) => {
|
|
const b = document.createElement('button');
|
|
b.className = 'route';
|
|
b.innerHTML = '<span class="verb ' + r.verb + '">' + r.verb + '</span>' +
|
|
'<span>' + r.path + '</span>';
|
|
b.title = r.hint;
|
|
b.onclick = () => select(i, b);
|
|
$('routes').appendChild(b);
|
|
});
|
|
|
|
function select(i, el) {
|
|
active = ROUTES[i];
|
|
document.querySelectorAll('.route').forEach((n) => n.classList.remove('on'));
|
|
el.classList.add('on');
|
|
$('arg').disabled = !active.arg;
|
|
$('arg').placeholder = active.arg ? active.hint : 'no argument';
|
|
$('arg').value = '';
|
|
$('run').disabled = false;
|
|
}
|
|
|
|
function status(state) { $('dot').className = 'panel-status ' + state; }
|
|
|
|
$('run').onclick = async () => {
|
|
if (!active) return;
|
|
status('processing');
|
|
$('out').className = 'out';
|
|
$('out').textContent = '…';
|
|
let path = active.path, init = { method: active.verb };
|
|
const v = $('arg').value.trim();
|
|
if (active.arg === 'key') path = path.replace('{key}', encodeURIComponent(v));
|
|
if (active.arg === 'jql') {
|
|
init.headers = { 'Content-Type': 'application/json' };
|
|
init.body = JSON.stringify({ jql: v });
|
|
}
|
|
try {
|
|
const res = await fetch(path, init);
|
|
const text = await res.text();
|
|
let body = text;
|
|
try { body = JSON.stringify(JSON.parse(text), null, 2); } catch (_) {}
|
|
$('out').textContent = res.status + ' ' + res.statusText + '\n\n' + body;
|
|
status(res.ok ? 'live' : 'error');
|
|
if (!res.ok) $('out').className = 'out err';
|
|
} catch (e) {
|
|
$('out').className = 'out err';
|
|
$('out').textContent = String(e) +
|
|
(location.protocol === 'file:' ? '\n\nThis page is not being served.' : '');
|
|
status('error');
|
|
}
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|