Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcff1f5dd2 | |||
| e0de3433c7 | |||
| fd4a38b7a9 | |||
| cdf5074214 | |||
| 3f96cc00d8 | |||
| 8fc1cde78a | |||
| 68ac25aa3f | |||
| aaff2dbce8 | |||
| 8e3a734031 | |||
| 077eaa2732 |
@@ -76,6 +76,7 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"target_commitish\": \"main\",
|
||||
\"name\": \"${{ steps.meta.outputs.name }} v${{ steps.meta.outputs.version }}\",
|
||||
\"body\": \"Automated build from CI/CD pipeline.\",
|
||||
\"draft\": false,
|
||||
@@ -104,3 +105,23 @@ jobs:
|
||||
done
|
||||
|
||||
echo "Release published: https://kjgr.io/${REPO}/releases/tag/$TAG"
|
||||
|
||||
- name: Upload to Plugin Registry
|
||||
if: always()
|
||||
env:
|
||||
REGISTRY_API_KEY: ${{ secrets.REGISTRY_API_KEY }}
|
||||
REGISTRY_URL: http://192.168.10.30:33435
|
||||
run: |
|
||||
if [ -z "$REGISTRY_API_KEY" ]; then
|
||||
echo "REGISTRY_API_KEY not set, skipping registry upload"
|
||||
exit 0
|
||||
fi
|
||||
for file in dist/*.fwp; do
|
||||
[ -f "$file" ] || continue
|
||||
FILENAME=$(basename "$file")
|
||||
echo "Publishing $FILENAME to registry..."
|
||||
RESULT=$(curl -s -X POST "${REGISTRY_URL}/api/v1/plugins/upload" \
|
||||
-H "X-API-Key: $REGISTRY_API_KEY" \
|
||||
-F "file=@$file")
|
||||
echo " $RESULT" | head -1
|
||||
done
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ build/
|
||||
*.so
|
||||
*.dll
|
||||
dist/
|
||||
synapse
|
||||
|
||||
23
Makefile
23
Makefile
@@ -14,6 +14,8 @@ SOURCE = webui.pp
|
||||
PLATFORM ?= linux-x86_64
|
||||
|
||||
SYNAPSE_DIR ?= synapse
|
||||
FCL_SRC ?= /opt/fpcup/fpcsrc/packages
|
||||
CROSS_UNITS ?= /opt/fpcup/fpc/units
|
||||
|
||||
FPCFLAGS = -Mobjfpc -Sh -CX -XXs -O2 -fPIC \
|
||||
-Fubuild -FUbuild \
|
||||
@@ -21,15 +23,32 @@ FPCFLAGS = -Mobjfpc -Sh -CX -XXs -O2 -fPIC \
|
||||
-Fu$(SYNAPSE_DIR) \
|
||||
-o$(TARGET)
|
||||
|
||||
ifeq ($(PLATFORM),linux-x86_64)
|
||||
FPCFLAGS += \
|
||||
-Fu$(FCL_SRC)/fcl-json/src \
|
||||
-Fu$(FCL_SRC)/fcl-web/src/base \
|
||||
-Fu$(FCL_SRC)/fcl-web/src/webdata \
|
||||
-Fu$(FCL_SRC)/fcl-xml/src \
|
||||
-Fu$(FCL_SRC)/fcl-base/src \
|
||||
-Fu$(FCL_SRC)/fcl-base/src/unix \
|
||||
-Fu$(FCL_SRC)/fcl-process/src \
|
||||
-Fu$(FCL_SRC)/fcl-process/src/unix \
|
||||
-Fu$(FCL_SRC)/fcl-net/src \
|
||||
-Fu$(FCL_SRC)/fcl-net/src/unix \
|
||||
-Fu$(FCL_SRC)/fcl-extra/src/unix
|
||||
endif
|
||||
|
||||
ifeq ($(PLATFORM),freebsd-x86_64)
|
||||
FPC = ppcrossx64
|
||||
FPCFLAGS += -Tfreebsd
|
||||
FPCFLAGS += -Tfreebsd \
|
||||
-Fu$(CROSS_UNITS)/x86_64-freebsd/*
|
||||
endif
|
||||
|
||||
ifeq ($(PLATFORM),win64)
|
||||
FPC = ppcrossx64
|
||||
TARGET = webui.dll
|
||||
FPCFLAGS += -Twin64 -o$(TARGET)
|
||||
FPCFLAGS += -Twin64 -o$(TARGET) \
|
||||
-Fu$(CROSS_UNITS)/x86_64-win64/*
|
||||
endif
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
25
README.md
Normal file
25
README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# WebUI Plugin
|
||||
|
||||
User-facing web interface for the Fastway BBS thin client. Provides a browser-based BBS experience with message reading/posting, user profile management, and real-time updates via WebSocket.
|
||||
|
||||
## Features
|
||||
|
||||
- Browser-based BBS access (no telnet client needed)
|
||||
- Message area browsing, reading, and posting
|
||||
- User authentication and profile management
|
||||
- Responsive dark-themed UI
|
||||
- Real-time message notifications via WebSocket
|
||||
- Works behind reverse proxies with SSL termination
|
||||
|
||||
## Configuration
|
||||
|
||||
In `fastwayclient.ini`:
|
||||
```ini
|
||||
[plugin.fw-webui]
|
||||
port=8081
|
||||
address=0.0.0.0
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0
|
||||
@@ -236,6 +236,7 @@ type
|
||||
procedure SessionReleaseNode(ANodeID: Integer);
|
||||
procedure SessionSetNodeUser(ANodeID: Integer; const AUsername: string; AUserID: Integer);
|
||||
procedure SessionSetNodeAddress(ANodeID: Integer; const AAddress: string);
|
||||
procedure SessionSetNodeStatus(ANodeID: Integer; const AStatus: string);
|
||||
|
||||
// Protocol detection (thin client only — stubs on primary server)
|
||||
// Transport plugins call these to discover and dispatch to protocol detectors.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.6",
|
||||
"api_version": 1,
|
||||
"description": "Web-based thin client for Fastway BBS - browser access to BBS features",
|
||||
"short_description": "Web-based BBS thin client",
|
||||
|
||||
383
web/css/wfc.css
Normal file
383
web/css/wfc.css
Normal file
@@ -0,0 +1,383 @@
|
||||
/* ================================================================
|
||||
Fastway BBS — Wait For Call (WFC) Screen
|
||||
================================================================ */
|
||||
|
||||
/* ---- Header ---- */
|
||||
|
||||
.wfc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wfc-bbs-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wfc-node-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
.wfc-header-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wfc-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wfc-time {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---- Content ---- */
|
||||
|
||||
.wfc-content {
|
||||
padding: 1rem 1.5rem 3rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---- Summary Bar ---- */
|
||||
|
||||
.wfc-summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.wfc-summary-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.wfc-summary-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wfc-summary-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* ---- Sections ---- */
|
||||
|
||||
.wfc-section {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.wfc-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.wfc-section-title {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wfc-section-badge {
|
||||
font-size: 0.7rem;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---- Node Grid ---- */
|
||||
|
||||
.wfc-node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wfc-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.3s, background 0.3s;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.wfc-node.active {
|
||||
border-color: var(--success);
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.06) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.wfc-node.waiting {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.wfc-node-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.wfc-node-num {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
min-width: 1.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wfc-node.active .wfc-node-num {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.wfc-node-dot-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.wfc-node-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.wfc-node-user {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wfc-node-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.wfc-node-addr {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wfc-node-waiting {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wfc-node-right {
|
||||
text-align: right;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.wfc-node-dur {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wfc-proto-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.6rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- Panels ---- */
|
||||
|
||||
.wfc-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wfc-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfc-panel-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wfc-panel-title {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wfc-panel-body {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* ---- Panel Rows ---- */
|
||||
|
||||
.wfc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
|
||||
.wfc-row.muted {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.wfc-row-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfc-row-name {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wfc-row-ver {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wfc-row-right {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfc-row-right.mono {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
.wfc-row-url {
|
||||
font-size: 0.7rem;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wfc-row-status-ok { color: var(--success); font-weight: 600; }
|
||||
.wfc-row-status-err { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* ---- Status Dots ---- */
|
||||
|
||||
.wfc-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfc-dot.online {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.wfc-dot.offline {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.wfc-dot.idle {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.wfc-dot.pulse {
|
||||
animation: wfc-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wfc-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); }
|
||||
}
|
||||
|
||||
/* ---- Status Bar ---- */
|
||||
|
||||
.wfc-statusbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.35rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.wfc-statusbar .wfc-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wfc-header { flex-direction: column; gap: 0.5rem; text-align: center; }
|
||||
.wfc-header-right { text-align: center; }
|
||||
.wfc-summary { flex-wrap: wrap; }
|
||||
.wfc-summary-item { min-width: 80px; }
|
||||
.wfc-node-grid { grid-template-columns: 1fr; }
|
||||
.wfc-panels { grid-template-columns: 1fr; }
|
||||
.wfc-content { padding: 0.75rem; padding-bottom: 3rem; }
|
||||
}
|
||||
226
web/js/wfc.js
Normal file
226
web/js/wfc.js
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Fastway BBS — Wait For Call screen
|
||||
* Fetches thin client local status and renders a real-time sysop dashboard.
|
||||
*/
|
||||
const WFC = {
|
||||
refreshMs: 2000,
|
||||
timer: null,
|
||||
data: null,
|
||||
|
||||
async init() {
|
||||
FWUI.initTheme();
|
||||
this.startClock();
|
||||
await this.refresh();
|
||||
this.timer = setInterval(() => this.refresh(), this.refreshMs);
|
||||
},
|
||||
|
||||
/* ---- Live Clock ---- */
|
||||
|
||||
startClock() {
|
||||
const tick = () => {
|
||||
const now = new Date();
|
||||
document.getElementById('wfc-date').textContent = now.toLocaleDateString('en-US', {
|
||||
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric'
|
||||
});
|
||||
document.getElementById('wfc-time').textContent = now.toLocaleTimeString('en-US', {
|
||||
hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
};
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
},
|
||||
|
||||
/* ---- Data Fetch ---- */
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const resp = await FWUI.get('/api/local/status');
|
||||
if (resp && resp.name !== undefined) {
|
||||
this.data = resp;
|
||||
this.render(resp);
|
||||
}
|
||||
} catch (e) {
|
||||
// silent
|
||||
}
|
||||
},
|
||||
|
||||
/* ---- Render ---- */
|
||||
|
||||
render(d) {
|
||||
// Header
|
||||
document.getElementById('wfc-node-name').textContent = d.name || 'Unknown';
|
||||
document.getElementById('wfc-version').textContent = 'v' + (d.version || '?');
|
||||
|
||||
// Summary bar
|
||||
const active = d.active_nodes || 0;
|
||||
const max = d.max_nodes || 0;
|
||||
document.getElementById('wfc-active').textContent = active;
|
||||
document.getElementById('wfc-available').textContent = max - active;
|
||||
document.getElementById('wfc-uptime').textContent = this.fmtUptime(d.uptime_seconds || 0);
|
||||
|
||||
const primaryDot = document.getElementById('wfc-primary-dot');
|
||||
primaryDot.className = 'wfc-dot ' + (d.primary_connected ? 'online' : 'offline');
|
||||
|
||||
// Nodes
|
||||
document.getElementById('wfc-nodes-badge').textContent = active + ' / ' + max;
|
||||
this.renderNodes(d);
|
||||
|
||||
// Panels
|
||||
this.renderProtocols(d);
|
||||
this.renderPlugins(d);
|
||||
this.renderPrimary(d);
|
||||
|
||||
// Status bar
|
||||
this.renderStatusBar(d);
|
||||
},
|
||||
|
||||
renderNodes(d) {
|
||||
const el = document.getElementById('wfc-nodes');
|
||||
const max = d.max_nodes || 0;
|
||||
const nodes = d.nodes || [];
|
||||
const map = {};
|
||||
nodes.forEach(n => { map[n.node] = n; });
|
||||
|
||||
let html = '';
|
||||
for (let i = 1; i <= max; i++) {
|
||||
const n = map[i];
|
||||
if (n) {
|
||||
const dur = n.connected_at ? this.fmtDuration(n.connected_at) : '';
|
||||
const user = FWUI.esc(n.username || 'Connecting...');
|
||||
const proto = FWUI.esc(n.protocol || '');
|
||||
const addr = FWUI.esc(n.remote_address || '');
|
||||
html += `<div class="wfc-node active">
|
||||
<div class="wfc-node-left">
|
||||
<span class="wfc-node-num">${i}</span>
|
||||
<span class="wfc-node-dot-wrap"><span class="wfc-dot online pulse"></span></span>
|
||||
</div>
|
||||
<div class="wfc-node-center">
|
||||
<div class="wfc-node-user">${user}</div>
|
||||
<div class="wfc-node-meta">
|
||||
<span class="wfc-proto-badge">${proto}</span>
|
||||
<span class="wfc-node-addr">${addr}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfc-node-right">
|
||||
<span class="wfc-node-dur">${dur}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="wfc-node waiting">
|
||||
<div class="wfc-node-left">
|
||||
<span class="wfc-node-num">${i}</span>
|
||||
<span class="wfc-node-dot-wrap"><span class="wfc-dot idle"></span></span>
|
||||
</div>
|
||||
<div class="wfc-node-center">
|
||||
<div class="wfc-node-waiting">Waiting for Call</div>
|
||||
</div>
|
||||
<div class="wfc-node-right"></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
el.innerHTML = html;
|
||||
},
|
||||
|
||||
renderProtocols(d) {
|
||||
const plugins = (d.plugins || []).filter(p => p.protocol);
|
||||
if (plugins.length === 0) {
|
||||
document.getElementById('wfc-protocols').innerHTML =
|
||||
'<div class="wfc-row muted">No protocol plugins</div>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
plugins.forEach(p => {
|
||||
const running = p.is_running || p.is_enabled;
|
||||
const port = p.listen_port ? ':' + p.listen_port : '';
|
||||
const conns = p.connections !== undefined ? p.connections : '';
|
||||
html += `<div class="wfc-row">
|
||||
<span class="wfc-row-left">
|
||||
<span class="wfc-dot ${running ? 'online' : 'offline'}"></span>
|
||||
<span class="wfc-row-name">${FWUI.esc(p.protocol || p.name)}${port}</span>
|
||||
</span>
|
||||
<span class="wfc-row-right">${conns !== '' ? conns + ' conn' : (running ? 'Listening' : 'Stopped')}</span>
|
||||
</div>`;
|
||||
});
|
||||
document.getElementById('wfc-protocols').innerHTML = html;
|
||||
},
|
||||
|
||||
renderPlugins(d) {
|
||||
const plugins = (d.plugins || []).filter(p => !p.protocol);
|
||||
if (plugins.length === 0) {
|
||||
document.getElementById('wfc-plugins').innerHTML =
|
||||
'<div class="wfc-row muted">No service plugins</div>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
plugins.forEach(p => {
|
||||
html += `<div class="wfc-row">
|
||||
<span class="wfc-row-left">
|
||||
<span class="wfc-dot ${p.is_enabled ? 'online' : 'offline'}"></span>
|
||||
<span class="wfc-row-name">${FWUI.esc(p.name)}</span>
|
||||
<span class="wfc-row-ver">v${FWUI.esc(p.version)}</span>
|
||||
</span>
|
||||
<span class="wfc-row-right">${p.is_enabled ? 'Active' : 'Disabled'}</span>
|
||||
</div>`;
|
||||
});
|
||||
document.getElementById('wfc-plugins').innerHTML = html;
|
||||
},
|
||||
|
||||
renderPrimary(d) {
|
||||
const connected = d.primary_connected;
|
||||
const url = FWUI.esc(d.primary_url || 'Not configured');
|
||||
document.getElementById('wfc-primary').innerHTML = `
|
||||
<div class="wfc-row">
|
||||
<span class="wfc-row-left">
|
||||
<span class="wfc-dot ${connected ? 'online' : 'offline'}"></span>
|
||||
<span class="wfc-row-name">Status</span>
|
||||
</span>
|
||||
<span class="wfc-row-right wfc-row-status-${connected ? 'ok' : 'err'}">${connected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
<div class="wfc-row">
|
||||
<span class="wfc-row-left"><span class="wfc-row-name">URL</span></span>
|
||||
<span class="wfc-row-right wfc-row-url">${url}</span>
|
||||
</div>
|
||||
<div class="wfc-row">
|
||||
<span class="wfc-row-left"><span class="wfc-row-name">Version</span></span>
|
||||
<span class="wfc-row-right mono">${FWUI.esc(d.version || '?')}</span>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
renderStatusBar(d) {
|
||||
const connected = d.primary_connected;
|
||||
const pCount = (d.plugins || []).length;
|
||||
const active = d.active_nodes || 0;
|
||||
const max = d.max_nodes || 0;
|
||||
|
||||
document.getElementById('wfc-sb-left').innerHTML =
|
||||
`<span class="wfc-dot ${connected ? 'online' : 'offline'}"></span> Primary ${connected ? 'Connected' : 'Disconnected'}`;
|
||||
document.getElementById('wfc-sb-center').textContent =
|
||||
`${pCount} plugins | ${active}/${max} nodes`;
|
||||
},
|
||||
|
||||
/* ---- Formatters ---- */
|
||||
|
||||
fmtUptime(sec) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm';
|
||||
},
|
||||
|
||||
fmtDuration(connectedAt) {
|
||||
try {
|
||||
const then = new Date(connectedAt + (connectedAt.indexOf('Z') < 0 ? 'Z' : ''));
|
||||
const diff = Math.max(0, Math.floor((Date.now() - then.getTime()) / 1000));
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => WFC.init());
|
||||
@@ -12,7 +12,7 @@
|
||||
<nav class="navbar">
|
||||
<div>
|
||||
<span class="navbar-brand">Fastway BBS</span>
|
||||
<span class="version">v0.2.0</span>
|
||||
<span class="version">{{VERSION}}</span>
|
||||
</div>
|
||||
<ul class="navbar-nav">
|
||||
<li><a href="/messages.html" class="active">Messages</a></li>
|
||||
|
||||
105
web/wfc.html
Normal file
105
web/wfc.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Fastway BBS — Wait For Call</title>
|
||||
<link rel="stylesheet" href="/css/webui.css?v=3">
|
||||
<link rel="stylesheet" href="/css/wfc.css?v=1">
|
||||
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('fw_theme') || 'dark');</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar">
|
||||
<span class="navbar-brand">Fastway BBS <span class="version" id="wfc-version"></span></span>
|
||||
<ul class="navbar-nav">
|
||||
<li><a href="/wfc.html" class="active">WFC</a></li>
|
||||
<li><a href="/messages.html">Messages</a></li>
|
||||
<li><button class="theme-toggle" id="theme-toggle" onclick="FWUI.toggleTheme()"></button></li>
|
||||
<li><button class="btn btn-sm btn-outline" onclick="FWUI.logout()">Logout</button></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- WFC Header -->
|
||||
<div class="wfc-header">
|
||||
<div class="wfc-header-left">
|
||||
<h1 class="wfc-bbs-name" id="wfc-bbs-name">Fastway BBS</h1>
|
||||
<span class="wfc-node-label" id="wfc-node-name">Connecting...</span>
|
||||
</div>
|
||||
<div class="wfc-header-right">
|
||||
<div class="wfc-date" id="wfc-date"></div>
|
||||
<div class="wfc-time" id="wfc-time"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="wfc-content">
|
||||
|
||||
<!-- Summary Bar -->
|
||||
<div class="wfc-summary">
|
||||
<div class="wfc-summary-item">
|
||||
<span class="wfc-summary-value" id="wfc-active">0</span>
|
||||
<span class="wfc-summary-label">Active</span>
|
||||
</div>
|
||||
<div class="wfc-summary-item">
|
||||
<span class="wfc-summary-value" id="wfc-available">0</span>
|
||||
<span class="wfc-summary-label">Available</span>
|
||||
</div>
|
||||
<div class="wfc-summary-item">
|
||||
<span class="wfc-summary-value" id="wfc-uptime">0:00</span>
|
||||
<span class="wfc-summary-label">Uptime</span>
|
||||
</div>
|
||||
<div class="wfc-summary-item" id="wfc-primary-summary">
|
||||
<span class="wfc-summary-value"><span class="wfc-dot online" id="wfc-primary-dot"></span></span>
|
||||
<span class="wfc-summary-label">Primary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Grid -->
|
||||
<div class="wfc-section">
|
||||
<div class="wfc-section-header">
|
||||
<h2 class="wfc-section-title">Nodes</h2>
|
||||
<span class="wfc-section-badge" id="wfc-nodes-badge">0 / 0</span>
|
||||
</div>
|
||||
<div class="wfc-node-grid" id="wfc-nodes"></div>
|
||||
</div>
|
||||
|
||||
<!-- Info Panels -->
|
||||
<div class="wfc-panels">
|
||||
<!-- Protocols -->
|
||||
<div class="wfc-panel">
|
||||
<div class="wfc-panel-header">
|
||||
<h3 class="wfc-panel-title">Protocols</h3>
|
||||
</div>
|
||||
<div class="wfc-panel-body" id="wfc-protocols"></div>
|
||||
</div>
|
||||
|
||||
<!-- Plugins -->
|
||||
<div class="wfc-panel">
|
||||
<div class="wfc-panel-header">
|
||||
<h3 class="wfc-panel-title">Plugins</h3>
|
||||
</div>
|
||||
<div class="wfc-panel-body" id="wfc-plugins"></div>
|
||||
</div>
|
||||
|
||||
<!-- Server Connection -->
|
||||
<div class="wfc-panel">
|
||||
<div class="wfc-panel-header">
|
||||
<h3 class="wfc-panel-title">Primary Server</h3>
|
||||
</div>
|
||||
<div class="wfc-panel-body" id="wfc-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="wfc-statusbar">
|
||||
<span id="wfc-sb-left"></span>
|
||||
<span id="wfc-sb-center"></span>
|
||||
<span id="wfc-sb-right">Refresh: 2s</span>
|
||||
</div>
|
||||
|
||||
<script src="/js/webui.js?v=3"></script>
|
||||
<script src="/js/wfc.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
46
webui.pp
46
webui.pp
@@ -17,7 +17,7 @@ uses
|
||||
fw_plugin_api;
|
||||
|
||||
const
|
||||
WEBUI_VERSION = '1.0.0';
|
||||
WEBUI_VERSION = '0.1.6';
|
||||
SESSION_COOKIE = 'fw_session';
|
||||
DEFAULT_PORT = 8888;
|
||||
SESSION_MAX_AGE = 86400; { 24 hours }
|
||||
@@ -75,6 +75,7 @@ type
|
||||
AResponse: TFPHTTPConnectionResponse);
|
||||
procedure HandleLogout(ARequest: TFPHTTPConnectionRequest;
|
||||
AResponse: TFPHTTPConnectionResponse);
|
||||
procedure HandleLocalStatus(AResponse: TFPHTTPConnectionResponse);
|
||||
procedure ProxyRequest(ARequest: TFPHTTPConnectionRequest;
|
||||
AResponse: TFPHTTPConnectionResponse; const AToken: string);
|
||||
procedure ServeStaticFile(ARequest: TFPHTTPConnectionRequest;
|
||||
@@ -238,7 +239,7 @@ end;
|
||||
|
||||
function TFWWebUIPlugin.GetName: string;
|
||||
begin
|
||||
Result := 'fw-webui';
|
||||
Result := 'webui';
|
||||
end;
|
||||
|
||||
function TFWWebUIPlugin.GetVersion: string;
|
||||
@@ -654,6 +655,13 @@ begin
|
||||
Exit;
|
||||
end;
|
||||
|
||||
{ Local status proxy — fetches from thin client status server }
|
||||
if Path = '/api/local/status' then
|
||||
begin
|
||||
HandleLocalStatus(AResponse);
|
||||
Exit;
|
||||
end;
|
||||
|
||||
{ API proxy: /api/v1/* and /plugins/* }
|
||||
if (Pos('/api/v1/', Path) = 1) or (Pos('/plugins/', Path) = 1) then
|
||||
begin
|
||||
@@ -833,6 +841,40 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
{ ======================================================================== }
|
||||
{ Local Status Proxy }
|
||||
{ ======================================================================== }
|
||||
|
||||
procedure TFWWebUIPlugin.HandleLocalStatus(AResponse: TFPHTTPConnectionResponse);
|
||||
var
|
||||
HTTP: THTTPSend;
|
||||
StatusPort: Integer;
|
||||
RespStr: string;
|
||||
begin
|
||||
StatusPort := StrToIntDef(FHost.ConfigGet('status_port', '8081'), 8081);
|
||||
|
||||
HTTP := THTTPSend.Create;
|
||||
try
|
||||
HTTP.Timeout := 3000;
|
||||
if HTTP.HTTPMethod('GET', Format('http://127.0.0.1:%d/status', [StatusPort])) then
|
||||
begin
|
||||
SetLength(RespStr, HTTP.Document.Size);
|
||||
HTTP.Document.Position := 0;
|
||||
if HTTP.Document.Size > 0 then
|
||||
HTTP.Document.Read(RespStr[1], HTTP.Document.Size)
|
||||
else
|
||||
RespStr := '{}';
|
||||
AResponse.Code := 200;
|
||||
AResponse.ContentType := 'application/json';
|
||||
AResponse.Content := RespStr;
|
||||
end
|
||||
else
|
||||
SendError(AResponse, 502, 'Cannot reach local status server');
|
||||
finally
|
||||
HTTP.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
{ ======================================================================== }
|
||||
{ API Proxy }
|
||||
{ ======================================================================== }
|
||||
|
||||
Reference in New Issue
Block a user