Files
OpsLog/frontend/src/components/WebPublishPanel.tsx
T
rouggy fb78b1c052 feat(webpublish): publish the log as a web page or CSV, with FTP upload
Settings -> Web publishing. Renders the last N QSOs with the columns the
operator picks, writes the file locally, and optionally uploads it by FTP or
explicit FTPS.

Local write FIRST, upload second, always. A network failure then leaves a good
file on disk that can be published another way, instead of a truncated one on
the server. The local write itself goes to a temp file and renames over the
target, so a reader — or a syncing client — never sees a half-written page.

The HTML page is fully self-contained: inline CSS, inline sort script, no font,
no CDN, no external request at all. It has to work on hosting that blocks
third-party requests, and a page about someone's hobby should not report its
readers to anyone.

Columns are a curated set, not "every ADIF field". This is published to the
public: RST and QSL status belong on it, the operator's home address does not.

Two triggers, both debounced through one path: a QSO is logged, or the optional
timer fires. Fifteen seconds of coalescing means a run of contacts produces one
upload rather than one per QSO, and nobody reading a web page can tell the
difference.

The config is one JSON blob under a single settings key, and that key is marked
sensitive: the FTP password lives inside it, so the whole blob is encrypted at
rest with the others. A locked vault reads back empty, which correctly reads as
"not configured" — publishing must not run with a password it cannot decrypt.
2026-08-10 09:07:08 +02:00

212 lines
10 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Upload, FolderOpen, Loader2 } from 'lucide-react';
import {
GetWebPublishConfig, SaveWebPublishConfig, WebPublishColumns,
TestWebPublishFTP, PublishLogNow, GetWebPublishStatus, PickBackupFolder,
} from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type Cfg = {
enabled: boolean; format: string; folder: string; file_name: string; title: string;
count: number; interval_min: number; columns: string[];
ftp_enabled: boolean; ftp_host: string; ftp_port: number; ftp_user: string;
ftp_password: string; ftp_tls: boolean; ftp_folder: string; ftp_file_name: string;
};
type Col = { key: string; header: string };
export function WebPublishPanel() {
const { t } = useI18n();
const [cfg, setCfg] = useState<Cfg | null>(null);
const [cols, setCols] = useState<Col[]>([]);
const [busy, setBusy] = useState<'' | 'test' | 'publish'>('');
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
// Raw text for the two numeric boxes: bound straight to the number they could
// not be cleared, the same trap as the Recent-QSOs Max field.
const [countText, setCountText] = useState('');
const [everyText, setEveryText] = useState('');
useEffect(() => {
(async () => {
try {
const [c, k, st] = await Promise.all([GetWebPublishConfig(), WebPublishColumns(), GetWebPublishStatus()]);
setCfg(c as any);
setCols((k ?? []) as Col[]);
setCountText(String((c as any).count ?? 100));
setEveryText(String((c as any).interval_min ?? 0));
const s: any = st;
if (s?.last_err) setErr(s.last_err);
else if (s?.last_run) setMsg(t('wpub.lastRun') + ' ' + s.last_run);
} catch (e: any) { setErr(String(e?.message ?? e)); }
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!cfg) return <p className="text-xs text-muted-foreground"></p>;
const set = (p: Partial<Cfg>) => setCfg((c) => (c ? { ...c, ...p } : c));
// Every control saves immediately: this panel has no Save button of its own,
// matching the other "saved instantly" panels.
const save = async (next: Cfg) => {
setCfg(next);
try { await SaveWebPublishConfig(next as any); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
};
const patch = (p: Partial<Cfg>) => save({ ...cfg, ...p });
const toggleCol = (key: string) => {
const has = cfg.columns?.includes(key);
patch({ columns: has ? cfg.columns.filter((c) => c !== key) : [...(cfg.columns ?? []), key] });
};
const run = async (what: 'test' | 'publish') => {
setBusy(what); setMsg(''); setErr('');
try {
const r = what === 'test' ? await TestWebPublishFTP(cfg as any) : await PublishLogNow();
setMsg(String(r));
} catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setBusy(''); }
};
return (
<div className="space-y-4 max-w-3xl">
<p className="text-xs text-muted-foreground leading-relaxed">{t('wpub.hint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} />
{t('wpub.enable')}
</label>
{cfg.enabled && (<>
{/* ── The file ── */}
<div className="space-y-2 border-t border-border/60 pt-3">
<Label className="text-xs font-semibold">{t('wpub.fileSection')}</Label>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">{t('wpub.format')}</Label>
<Select value={cfg.format} onValueChange={(v) => patch({ format: v })}>
<SelectTrigger className="h-8 w-56"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="html">{t('wpub.formatHtml')}</SelectItem>
<SelectItem value="csv">{t('wpub.formatCsv')}</SelectItem>
</SelectContent>
</Select>
<Label className="text-sm">{t('wpub.folder')}</Label>
<div className="flex gap-2">
<Input className="h-8 font-mono text-xs flex-1" value={cfg.folder}
onChange={(e) => set({ folder: e.target.value })} onBlur={() => patch({ folder: cfg.folder })} />
<Button variant="outline" size="sm" className="h-8" onClick={async () => {
try { const p = await PickBackupFolder(); if (p) patch({ folder: p }); } catch { /* cancelled */ }
}}><FolderOpen className="size-3.5" /> {t('wpub.browse')}</Button>
</div>
<Label className="text-sm">{t('wpub.fileName')}</Label>
<Input className="h-8 w-56 font-mono text-xs" value={cfg.file_name}
onChange={(e) => set({ file_name: e.target.value })} onBlur={() => patch({ file_name: cfg.file_name })} />
<Label className="text-sm">{t('wpub.title')}</Label>
<Input className="h-8" placeholder={t('wpub.titlePh')} value={cfg.title}
onChange={(e) => set({ title: e.target.value })} onBlur={() => patch({ title: cfg.title })} />
<Label className="text-sm">{t('wpub.count')}</Label>
<Input type="number" min={1} max={100000} className="h-8 w-28 font-mono text-xs" value={countText}
onChange={(e) => setCountText(e.target.value)}
onBlur={() => {
const n = Math.floor(Number(countText));
if (Number.isFinite(n) && n > 0) patch({ count: n }); else setCountText(String(cfg.count));
}} />
<Label className="text-sm">{t('wpub.every')}</Label>
<div className="flex items-center gap-2">
<Input type="number" min={0} max={1440} className="h-8 w-28 font-mono text-xs" value={everyText}
onChange={(e) => setEveryText(e.target.value)}
onBlur={() => {
const n = Math.floor(Number(everyText));
if (Number.isFinite(n) && n >= 0) patch({ interval_min: n }); else setEveryText(String(cfg.interval_min));
}} />
<span className="text-xs text-muted-foreground">{t('wpub.everyHint')}</span>
</div>
</div>
</div>
{/* ── Columns ── */}
<div className="space-y-2 border-t border-border/60 pt-3">
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
<div className="flex flex-wrap gap-1.5">
{cols.map((c) => {
const on = cfg.columns?.includes(c.key);
return (
<button key={c.key} type="button" onClick={() => toggleCol(c.key)}
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
{c.header}
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground">{t('wpub.columnsHint')}</p>
</div>
{/* ── Upload ── */}
<div className="space-y-2 border-t border-border/60 pt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.ftp_enabled} onCheckedChange={(c) => patch({ ftp_enabled: !!c })} />
{t('wpub.ftpEnable')}
</label>
{cfg.ftp_enabled && (
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">{t('wpub.ftpHost')}</Label>
<div className="flex gap-2">
<Input className="h-8 flex-1" placeholder="ftp.example.com" value={cfg.ftp_host}
onChange={(e) => set({ ftp_host: e.target.value })} onBlur={() => patch({ ftp_host: cfg.ftp_host })} />
<Input type="number" className="h-8 w-24 font-mono text-xs" value={cfg.ftp_port}
onChange={(e) => set({ ftp_port: Number(e.target.value) || 21 })}
onBlur={() => patch({ ftp_port: cfg.ftp_port })} />
</div>
<Label className="text-sm">{t('wpub.ftpUser')}</Label>
<Input className="h-8" value={cfg.ftp_user}
onChange={(e) => set({ ftp_user: e.target.value })} onBlur={() => patch({ ftp_user: cfg.ftp_user })} />
<Label className="text-sm">{t('wpub.ftpPassword')}</Label>
<Input type="password" className="h-8" value={cfg.ftp_password}
onChange={(e) => set({ ftp_password: e.target.value })} onBlur={() => patch({ ftp_password: cfg.ftp_password })} />
<Label className="text-sm">{t('wpub.ftpFolder')}</Label>
<Input className="h-8 font-mono text-xs" placeholder="/www/log" value={cfg.ftp_folder}
onChange={(e) => set({ ftp_folder: e.target.value })} onBlur={() => patch({ ftp_folder: cfg.ftp_folder })} />
<Label className="text-sm">{t('wpub.ftpFileName')}</Label>
<Input className="h-8 w-56 font-mono text-xs" value={cfg.ftp_file_name}
onChange={(e) => set({ ftp_file_name: e.target.value })} onBlur={() => patch({ ftp_file_name: cfg.ftp_file_name })} />
<span />
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.ftp_tls} onCheckedChange={(c) => patch({ ftp_tls: !!c })} />
{t('wpub.ftpTls')}
</label>
</div>
)}
</div>
<div className="flex items-center gap-3 border-t border-border/60 pt-3 flex-wrap">
<Button size="sm" onClick={() => run('publish')} disabled={busy !== ''}>
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
{t('wpub.publishNow')}
</Button>
{cfg.ftp_enabled && (
<Button variant="outline" size="sm" onClick={() => run('test')} disabled={busy !== ''}>
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : null}
{t('wpub.testFtp')}
</Button>
)}
</div>
</>)}
{msg && <p className="text-xs text-success break-all">{msg}</p>}
{err && <p className="text-xs text-danger break-all">{err}</p>}
</div>
);
}