feat: added live status for TM74TFR
This commit is contained in:
@@ -769,6 +769,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
// Anonymous usage heartbeat (once/day) so we can gauge active users. No-op
|
||||
// when disabled in Preferences or until the PostHog key is configured.
|
||||
go a.sendTelemetryHeartbeat()
|
||||
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
|
||||
|
||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* OpsLog multi-operator LIVE STATUS renderer.
|
||||
*
|
||||
* Reads the shared `live_status` table that every OpsLog instance heartbeats
|
||||
* (operator call + station call + freq/band/mode, refreshed every ~15s) and
|
||||
* shows the operators active in the last 2 minutes.
|
||||
*
|
||||
* Put this file on YOUR web server (the one reachable from the internet), point
|
||||
* it at the SAME MySQL database OpsLog uses for the shared logbook, and embed it
|
||||
* on the QRZ.com bio of the station call:
|
||||
*
|
||||
* <img src="https://your-server/tm74-status.php?img=1"> (image, cached ~min by QRZ)
|
||||
* or <a href="https://your-server/tm74-status.php">Live operators</a> (real-time page)
|
||||
*
|
||||
* QRZ strips <script>/<iframe>, so only an <img> auto-updates the page.
|
||||
*/
|
||||
|
||||
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
||||
$DB_NAME = 'opslog'; // database name
|
||||
$DB_USER = 'opslog';
|
||||
$DB_PASS = 'CHANGE_ME';
|
||||
$STALE_SECONDS = 120; // an operator is "active" if seen within this window
|
||||
|
||||
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
||||
if ($mysqli->connect_errno) {
|
||||
http_response_code(500);
|
||||
exit('DB error');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$sql = "SELECT operator, station, freq_hz, band, mode, updated_at
|
||||
FROM live_status
|
||||
WHERE updated_at >= UTC_TIMESTAMP() - INTERVAL ? SECOND
|
||||
ORDER BY band, freq_hz";
|
||||
if ($stmt = $mysqli->prepare($sql)) {
|
||||
$stmt->bind_param('i', $STALE_SECONDS);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||
}
|
||||
|
||||
$station = $rows ? $rows[0]['station'] : 'OpsLog';
|
||||
$fmtFreq = function ($hz) { return $hz > 0 ? number_format($hz / 1e6, 3) . ' MHz' : '—'; };
|
||||
|
||||
// ── Image output (SVG) for the QRZ <img> embed: ?img=1 ──────────────────────
|
||||
if (isset($_GET['img'])) {
|
||||
header('Content-Type: image/svg+xml');
|
||||
header('Cache-Control: no-cache, max-age=30');
|
||||
$rowH = 26; $h = 44 + max(1, count($rows)) * $rowH; $w = 440;
|
||||
echo "<svg xmlns='http://www.w3.org/2000/svg' width='$w' height='$h' font-family='Segoe UI,Arial'>";
|
||||
echo "<rect width='$w' height='$h' rx='8' fill='#0f172a'/>";
|
||||
echo "<text x='14' y='26' fill='#38bdf8' font-size='15' font-weight='bold'>" . htmlspecialchars($station) . " — live operators</text>";
|
||||
$y = 44 + 18;
|
||||
if (!$rows) {
|
||||
echo "<text x='14' y='$y' fill='#94a3b8' font-size='13'>No operator active right now.</text>";
|
||||
}
|
||||
foreach ($rows as $r) {
|
||||
$line = sprintf('%-10s %-4s %-9s %s', $r['operator'], $r['band'], $r['mode'], $fmtFreq($r['freq_hz']));
|
||||
echo "<text x='14' y='$y' fill='#e2e8f0' font-size='13' font-family='monospace'>" . htmlspecialchars($line) . "</text>";
|
||||
$y += $rowH;
|
||||
}
|
||||
echo "</svg>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── HTML page (real-time when opened directly; auto-refreshes every 20s) ─────
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?><!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<title><?= htmlspecialchars($station) ?> — live operators</title>
|
||||
<style>
|
||||
body { font-family: Segoe UI, Arial, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:16px; }
|
||||
h1 { color:#38bdf8; font-size:18px; margin:0 0 12px; }
|
||||
table { border-collapse:collapse; width:100%; max-width:560px; }
|
||||
th,td { text-align:left; padding:6px 12px; border-bottom:1px solid #1e293b; font-size:14px; }
|
||||
th { color:#94a3b8; text-transform:uppercase; font-size:11px; letter-spacing:.05em; }
|
||||
td.mono { font-family:monospace; }
|
||||
.none { color:#94a3b8; }
|
||||
</style></head><body>
|
||||
<h1><?= htmlspecialchars($station) ?> — operators active now</h1>
|
||||
<?php if (!$rows): ?>
|
||||
<p class="none">No operator active right now.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Operator</th><th>Band</th><th>Mode</th><th>Frequency</th></tr>
|
||||
<?php foreach ($rows as $r): ?>
|
||||
<tr>
|
||||
<td><strong><?= htmlspecialchars($r['operator']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($r['band']) ?></td>
|
||||
<td><?= htmlspecialchars($r['mode']) ?></td>
|
||||
<td class="mono"><?= $fmtFreq($r['freq_hz']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</body></html>
|
||||
+10
-7
@@ -2063,6 +2063,14 @@ export default function App() {
|
||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
// DX country flag, shown large next to RST (moved here from the Country field).
|
||||
const flagBlock = flagURL(details.dxcc) ? (
|
||||
<div className="flex flex-col justify-end shrink-0">
|
||||
<img src={flagURL(details.dxcc)} alt={country} title={country}
|
||||
className="h-9 rounded-[3px] border border-border/60 shadow-sm"
|
||||
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||
</div>
|
||||
) : null;
|
||||
// Deferred-entry date: only shown when the start time is locked (back-entering
|
||||
// a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
|
||||
const dateBlock = locks.start ? (
|
||||
@@ -2185,13 +2193,7 @@ export default function App() {
|
||||
);
|
||||
const countryRow = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1.5">
|
||||
Country
|
||||
{flagURL(details.dxcc) && (
|
||||
<img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm mr-0.5"
|
||||
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
</Label>
|
||||
<Label className="w-20 shrink-0">Country</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
|
||||
</div>
|
||||
@@ -2927,6 +2929,7 @@ export default function App() {
|
||||
{callsignBlock}
|
||||
{rstTxBlock}
|
||||
{rstRxBlock}
|
||||
{flagBlock}
|
||||
<div className="ml-auto flex gap-2">
|
||||
{dateBlock}
|
||||
{startBlock}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
GetDataDir,
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
@@ -448,6 +449,25 @@ function TelemetryToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
// LiveStatusToggle publishes this operator's current activity (call + band +
|
||||
// freq + mode) to the shared MySQL `live_status` table every ~15s, for multi-op
|
||||
// events — a small web script on your server renders it for the QRZ page. Only
|
||||
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||
function LiveStatusToggle() {
|
||||
const [on, setOn] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
GetLiveStatusEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
|
||||
}, []);
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={on} disabled={!loaded}
|
||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
||||
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL — feeds a QRZ live page)</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||
// panes show, independently: the great-circle map, the locator street map, the
|
||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||
@@ -3351,6 +3371,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
|
||||
</label>
|
||||
<TelemetryToggle />
|
||||
<LiveStatusToggle />
|
||||
|
||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
|
||||
|
||||
|
||||
Vendored
+4
@@ -217,6 +217,8 @@ export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
|
||||
export function GetLogbookRevision():Promise<string>;
|
||||
@@ -449,6 +451,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
@@ -406,6 +406,10 @@ export function GetListsSettings() {
|
||||
return window['go']['main']['App']['GetListsSettings']();
|
||||
}
|
||||
|
||||
export function GetLiveStatusEnabled() {
|
||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||
}
|
||||
|
||||
export function GetLogFilePath() {
|
||||
return window['go']['main']['App']['GetLogFilePath']();
|
||||
}
|
||||
@@ -870,6 +874,10 @@ export function SetDVKLabel(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetLiveStatusEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetPassphrase(arg1) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// Live operator status — for multi-operator events on a SHARED MySQL logbook
|
||||
// (e.g. a special-event call like TM74FR with several ops on different bands).
|
||||
// Each OpsLog instance heartbeats its current activity (operator call + station
|
||||
// call + freq/band/mode from CAT) into a `live_status` table every ~15s. A tiny
|
||||
// web script on the operator's own server reads that table and renders a live
|
||||
// page/image that the QRZ.com bio can embed (`<img src=…>`). OpsLog only WRITES
|
||||
// to the DB — it is not a web server. Rows older than a couple of minutes are
|
||||
// "stale" (operator went offline); the web side ignores them.
|
||||
|
||||
const keyLiveStatusEnabled = "livestatus.enabled"
|
||||
|
||||
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||
func (a *App) GetLiveStatusEnabled() bool {
|
||||
if a.settings == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := a.settings.Get(a.ctx, keyLiveStatusEnabled)
|
||||
return strings.TrimSpace(v) == "1"
|
||||
}
|
||||
|
||||
// SetLiveStatusEnabled turns live-status publishing on or off (off also removes
|
||||
// this operator's row immediately).
|
||||
func (a *App) SetLiveStatusEnabled(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
val := "0"
|
||||
if on {
|
||||
val = "1"
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyLiveStatusEnabled, val); err != nil {
|
||||
return err
|
||||
}
|
||||
if on {
|
||||
go a.publishLiveStatus() // show up right away
|
||||
} else {
|
||||
a.clearLiveStatus()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||
// startup; cheap no-op when disabled or not on MySQL.
|
||||
func (a *App) liveStatusLoop() {
|
||||
t := time.NewTicker(15 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
a.clearLiveStatus()
|
||||
return
|
||||
case <-t.C:
|
||||
a.publishLiveStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// liveStatusActive reports whether publishing should run (MySQL logbook + on).
|
||||
func (a *App) liveStatusActive() bool {
|
||||
return a.logDb != nil && a.dbBackend == "mysql" && a.GetLiveStatusEnabled()
|
||||
}
|
||||
|
||||
// liveStatusOperator returns this instance's operator id (the operator callsign,
|
||||
// falling back to the station callsign for a single-op setup).
|
||||
func (a *App) liveStatusOperator() (op, station string) {
|
||||
if a.settings == nil {
|
||||
return "", ""
|
||||
}
|
||||
o, _ := a.settings.Get(a.ctx, keyStationOperator)
|
||||
s, _ := a.settings.Get(a.ctx, keyStationCallsign)
|
||||
op = strings.ToUpper(strings.TrimSpace(o))
|
||||
station = strings.ToUpper(strings.TrimSpace(s))
|
||||
if op == "" {
|
||||
op = station
|
||||
}
|
||||
return op, station
|
||||
}
|
||||
|
||||
// publishLiveStatus upserts this operator's current activity. Best effort.
|
||||
func (a *App) publishLiveStatus() {
|
||||
if !a.liveStatusActive() {
|
||||
return
|
||||
}
|
||||
op, station := a.liveStatusOperator()
|
||||
if op == "" {
|
||||
return
|
||||
}
|
||||
var freqHz int64
|
||||
var band, mode string
|
||||
if a.cat != nil {
|
||||
st := a.cat.State()
|
||||
if st.Connected {
|
||||
freqHz, band, mode = st.FreqHz, st.Band, st.Mode
|
||||
}
|
||||
}
|
||||
if err := a.ensureLiveStatusTable(); err != nil {
|
||||
applog.Printf("livestatus: ensure table: %v", err)
|
||||
return
|
||||
}
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
||||
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
||||
op, station, freqHz, band, mode)
|
||||
if err != nil {
|
||||
applog.Printf("livestatus: publish: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ensureLiveStatusTable() error {
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||
"station VARCHAR(32), "+
|
||||
"freq_hz BIGINT, "+
|
||||
"band VARCHAR(16), "+
|
||||
"mode VARCHAR(16), "+
|
||||
"updated_at DATETIME)")
|
||||
return err
|
||||
}
|
||||
|
||||
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||
func (a *App) clearLiveStatus() {
|
||||
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||
return
|
||||
}
|
||||
op, _ := a.liveStatusOperator()
|
||||
if op == "" {
|
||||
return
|
||||
}
|
||||
_, _ = a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op)
|
||||
}
|
||||
Reference in New Issue
Block a user