seo-intel 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/analyses/watch/diff.js +158 -0
- package/analyses/watch/health.js +78 -0
- package/analyses/watch/index.js +215 -0
- package/cli.js +155 -14
- package/db/db.js +73 -0
- package/lib/export-zip.js +102 -0
- package/package.json +1 -1
- package/reports/generate-html.js +253 -11
- package/reports/gsc-loader.js +14 -4
- package/server.js +311 -2
- package/setup/checks.js +9 -2
- package/setup/web-routes.js +37 -3
- package/setup/wizard.html +484 -323
package/server.js
CHANGED
|
@@ -96,6 +96,8 @@ const MIME = {
|
|
|
96
96
|
'.png': 'image/png',
|
|
97
97
|
'.svg': 'image/svg+xml',
|
|
98
98
|
'.md': 'text/markdown; charset=utf-8',
|
|
99
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
100
|
+
'.zip': 'application/zip',
|
|
99
101
|
};
|
|
100
102
|
|
|
101
103
|
// ── Read progress with PID liveness check (mirrors cli.js) ──
|
|
@@ -449,7 +451,7 @@ async function handleRequest(req, res) {
|
|
|
449
451
|
|
|
450
452
|
try {
|
|
451
453
|
const data = JSON.parse(rawJson);
|
|
452
|
-
const stamp = Date.
|
|
454
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
453
455
|
const baseName = `${project}-actions-${stamp}`;
|
|
454
456
|
writeFileSync(join(REPORTS_DIR, `${baseName}.json`), JSON.stringify(data, null, 2), 'utf8');
|
|
455
457
|
writeFileSync(join(REPORTS_DIR, `${baseName}.md`), buildActionsMarkdown(data), 'utf8');
|
|
@@ -586,6 +588,313 @@ async function handleRequest(req, res) {
|
|
|
586
588
|
return;
|
|
587
589
|
}
|
|
588
590
|
|
|
591
|
+
// ─── API: Universal Export Download ───
|
|
592
|
+
if (req.method === 'GET' && path === '/api/export/download') {
|
|
593
|
+
try {
|
|
594
|
+
const project = url.searchParams.get('project');
|
|
595
|
+
const section = url.searchParams.get('section') || 'all';
|
|
596
|
+
const format = url.searchParams.get('format') || 'json';
|
|
597
|
+
|
|
598
|
+
if (!project) { json(res, 400, { error: 'Missing project' }); return; }
|
|
599
|
+
|
|
600
|
+
const { getDb } = await import('./db/db.js');
|
|
601
|
+
const db = getDb(join(__dirname, 'seo-intel.db'));
|
|
602
|
+
const configPath = join(__dirname, 'config', `${project}.json`);
|
|
603
|
+
const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : null;
|
|
604
|
+
|
|
605
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
606
|
+
const { createZip } = await import('./lib/export-zip.js');
|
|
607
|
+
|
|
608
|
+
const SECTIONS = ['aeo', 'insights', 'technical', 'keywords', 'pages', 'watch', 'schemas', 'headings', 'links'];
|
|
609
|
+
|
|
610
|
+
function querySection(sec) {
|
|
611
|
+
switch (sec) {
|
|
612
|
+
case 'aeo': {
|
|
613
|
+
try {
|
|
614
|
+
return db.prepare(`
|
|
615
|
+
SELECT cs.score, cs.entity_authority, cs.structured_claims, cs.answer_density,
|
|
616
|
+
cs.qa_proximity, cs.freshness, cs.schema_coverage, cs.tier, cs.ai_intents,
|
|
617
|
+
p.url, p.title, p.word_count, d.domain, d.role
|
|
618
|
+
FROM citability_scores cs
|
|
619
|
+
JOIN pages p ON p.id = cs.page_id
|
|
620
|
+
JOIN domains d ON d.id = p.domain_id
|
|
621
|
+
WHERE d.project = ?
|
|
622
|
+
ORDER BY d.role ASC, cs.score ASC
|
|
623
|
+
`).all(project);
|
|
624
|
+
} catch { return []; }
|
|
625
|
+
}
|
|
626
|
+
case 'insights': {
|
|
627
|
+
try {
|
|
628
|
+
const rows = db.prepare(
|
|
629
|
+
`SELECT * FROM insights WHERE project = ? AND status = 'active' ORDER BY type, last_seen DESC`
|
|
630
|
+
).all(project);
|
|
631
|
+
return rows.map(r => {
|
|
632
|
+
try { return { ...JSON.parse(r.data), _type: r.type, _id: r.id, _first_seen: r.first_seen, _last_seen: r.last_seen }; }
|
|
633
|
+
catch { return { _type: r.type, _id: r.id, raw: r.data }; }
|
|
634
|
+
});
|
|
635
|
+
} catch { return []; }
|
|
636
|
+
}
|
|
637
|
+
case 'technical': {
|
|
638
|
+
try {
|
|
639
|
+
return db.prepare(`
|
|
640
|
+
SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
|
|
641
|
+
t.has_canonical, t.has_og_tags, t.has_schema, t.has_robots, t.is_mobile_ok,
|
|
642
|
+
d.domain, d.role
|
|
643
|
+
FROM pages p
|
|
644
|
+
JOIN domains d ON d.id = p.domain_id
|
|
645
|
+
LEFT JOIN technical t ON t.page_id = p.id
|
|
646
|
+
WHERE d.project = ?
|
|
647
|
+
ORDER BY d.domain, p.url
|
|
648
|
+
`).all(project);
|
|
649
|
+
} catch { return []; }
|
|
650
|
+
}
|
|
651
|
+
case 'keywords': {
|
|
652
|
+
try {
|
|
653
|
+
return db.prepare(`
|
|
654
|
+
SELECT k.keyword, d.domain, d.role, k.location, COUNT(*) as freq
|
|
655
|
+
FROM keywords k
|
|
656
|
+
JOIN pages p ON p.id = k.page_id
|
|
657
|
+
JOIN domains d ON d.id = p.domain_id
|
|
658
|
+
WHERE d.project = ?
|
|
659
|
+
GROUP BY k.keyword, d.domain
|
|
660
|
+
ORDER BY freq DESC
|
|
661
|
+
`).all(project);
|
|
662
|
+
} catch { return []; }
|
|
663
|
+
}
|
|
664
|
+
case 'pages': {
|
|
665
|
+
try {
|
|
666
|
+
return db.prepare(`
|
|
667
|
+
SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
|
|
668
|
+
p.title, p.meta_desc, p.published_date, p.modified_date,
|
|
669
|
+
p.crawled_at, p.first_seen_at, d.domain, d.role
|
|
670
|
+
FROM pages p
|
|
671
|
+
JOIN domains d ON d.id = p.domain_id
|
|
672
|
+
WHERE d.project = ?
|
|
673
|
+
ORDER BY d.domain, p.url
|
|
674
|
+
`).all(project);
|
|
675
|
+
} catch { return []; }
|
|
676
|
+
}
|
|
677
|
+
case 'watch': {
|
|
678
|
+
try {
|
|
679
|
+
const snap = db.prepare('SELECT * FROM watch_snapshots WHERE project = ? ORDER BY created_at DESC LIMIT 1').get(project);
|
|
680
|
+
if (!snap) return [];
|
|
681
|
+
const events = db.prepare('SELECT * FROM watch_events WHERE snapshot_id = ? ORDER BY severity, event_type').all(snap.id);
|
|
682
|
+
const pages = db.prepare('SELECT * FROM watch_page_states WHERE snapshot_id = ?').all(snap.id);
|
|
683
|
+
return { snapshot: snap, events, pages };
|
|
684
|
+
} catch { return []; }
|
|
685
|
+
}
|
|
686
|
+
case 'schemas': {
|
|
687
|
+
try {
|
|
688
|
+
return db.prepare(`
|
|
689
|
+
SELECT d.domain, d.role, p.url, ps.schema_type, ps.name, ps.description,
|
|
690
|
+
ps.rating, ps.rating_count, ps.price, ps.currency, ps.author,
|
|
691
|
+
ps.date_published, ps.date_modified
|
|
692
|
+
FROM page_schemas ps
|
|
693
|
+
JOIN pages p ON p.id = ps.page_id
|
|
694
|
+
JOIN domains d ON d.id = p.domain_id
|
|
695
|
+
WHERE d.project = ?
|
|
696
|
+
ORDER BY d.domain, ps.schema_type
|
|
697
|
+
`).all(project);
|
|
698
|
+
} catch { return []; }
|
|
699
|
+
}
|
|
700
|
+
case 'headings': {
|
|
701
|
+
try {
|
|
702
|
+
return db.prepare(`
|
|
703
|
+
SELECT d.domain, d.role, p.url, h.level, h.text
|
|
704
|
+
FROM headings h
|
|
705
|
+
JOIN pages p ON p.id = h.page_id
|
|
706
|
+
JOIN domains d ON d.id = p.domain_id
|
|
707
|
+
WHERE d.project = ?
|
|
708
|
+
ORDER BY d.domain, p.url, h.level
|
|
709
|
+
`).all(project);
|
|
710
|
+
} catch { return []; }
|
|
711
|
+
}
|
|
712
|
+
case 'links': {
|
|
713
|
+
try {
|
|
714
|
+
return db.prepare(`
|
|
715
|
+
SELECT l.source_page_id, l.target_url, l.anchor_text, l.is_internal,
|
|
716
|
+
p.url as source_url, d.domain, d.role
|
|
717
|
+
FROM links l
|
|
718
|
+
JOIN pages p ON p.id = l.source_page_id
|
|
719
|
+
JOIN domains d ON d.id = p.domain_id
|
|
720
|
+
WHERE d.project = ?
|
|
721
|
+
ORDER BY d.domain, p.url
|
|
722
|
+
`).all(project);
|
|
723
|
+
} catch { return []; }
|
|
724
|
+
}
|
|
725
|
+
default: return [];
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function toCSV(rows) {
|
|
730
|
+
if (!rows || (Array.isArray(rows) && !rows.length)) return '';
|
|
731
|
+
const arr = Array.isArray(rows) ? rows : (rows.events || rows.pages || []);
|
|
732
|
+
if (!arr.length) return '';
|
|
733
|
+
const keys = Object.keys(arr[0]);
|
|
734
|
+
const escape = (v) => {
|
|
735
|
+
if (v == null) return '';
|
|
736
|
+
const s = String(v);
|
|
737
|
+
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
|
738
|
+
};
|
|
739
|
+
return [keys.join(','), ...arr.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function toMarkdown(sec, data, proj) {
|
|
743
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
744
|
+
const header = `# SEO Intel — ${sec.charAt(0).toUpperCase() + sec.slice(1)} Export\n\n- Project: ${proj}\n- Date: ${date}\n\n`;
|
|
745
|
+
if (!data || (Array.isArray(data) && !data.length)) return header + '_No data available._\n';
|
|
746
|
+
|
|
747
|
+
switch (sec) {
|
|
748
|
+
case 'aeo': {
|
|
749
|
+
const targetRows = data.filter(r => r.role === 'target' || r.role === 'owned');
|
|
750
|
+
const avg = targetRows.length ? Math.round(targetRows.reduce((a, r) => a + r.score, 0) / targetRows.length) : 0;
|
|
751
|
+
let md = header + `## Summary\n\n- Pages scored: ${data.length}\n- Target average: ${avg}/100\n\n`;
|
|
752
|
+
md += '## Page Scores\n\n| Score | Tier | URL | Title | Weakest Signals |\n|-------|------|-----|-------|-----------------|\n';
|
|
753
|
+
for (const r of data) {
|
|
754
|
+
const signals = ['entity_authority', 'structured_claims', 'answer_density', 'qa_proximity', 'freshness', 'schema_coverage'];
|
|
755
|
+
const weakest = signals.sort((a, b) => (r[a] || 0) - (r[b] || 0)).slice(0, 2).map(s => s.replace(/_/g, ' ')).join(', ');
|
|
756
|
+
md += `| ${r.score} | ${r.tier} | ${r.url} | ${(r.title || '').slice(0, 50)} | ${weakest} |\n`;
|
|
757
|
+
}
|
|
758
|
+
return md;
|
|
759
|
+
}
|
|
760
|
+
case 'insights': {
|
|
761
|
+
let md = header + `## Active Insights (${data.length})\n\n`;
|
|
762
|
+
const grouped = {};
|
|
763
|
+
for (const r of data) { (grouped[r._type] ||= []).push(r); }
|
|
764
|
+
for (const [type, items] of Object.entries(grouped)) {
|
|
765
|
+
md += `### ${type.replace(/_/g, ' ')} (${items.length})\n\n`;
|
|
766
|
+
for (const item of items) {
|
|
767
|
+
const desc = item.phrase || item.keyword || item.title || item.page || item.message || JSON.stringify(item).slice(0, 120);
|
|
768
|
+
md += `- ${desc}\n`;
|
|
769
|
+
}
|
|
770
|
+
md += '\n';
|
|
771
|
+
}
|
|
772
|
+
return md;
|
|
773
|
+
}
|
|
774
|
+
case 'technical': {
|
|
775
|
+
let md = header + '## Technical Audit\n\n| URL | Status | Words | Load ms | Canonical | OG | Schema | Robots | Mobile |\n|-----|--------|-------|---------|-----------|-----|--------|--------|--------|\n';
|
|
776
|
+
for (const r of data) {
|
|
777
|
+
md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${r.load_ms || 0} | ${r.has_canonical ? 'Y' : 'N'} | ${r.has_og_tags ? 'Y' : 'N'} | ${r.has_schema ? 'Y' : 'N'} | ${r.has_robots ? 'Y' : 'N'} | ${r.is_mobile_ok ? 'Y' : 'N'} |\n`;
|
|
778
|
+
}
|
|
779
|
+
return md;
|
|
780
|
+
}
|
|
781
|
+
case 'keywords': {
|
|
782
|
+
let md = header + '## Keyword Matrix\n\n| Keyword | Domain | Role | Location | Frequency |\n|---------|--------|------|----------|-----------|\n';
|
|
783
|
+
for (const r of data.slice(0, 500)) {
|
|
784
|
+
md += `| ${r.keyword} | ${r.domain} | ${r.role} | ${r.location || ''} | ${r.freq} |\n`;
|
|
785
|
+
}
|
|
786
|
+
if (data.length > 500) md += `\n_...and ${data.length - 500} more rows._\n`;
|
|
787
|
+
return md;
|
|
788
|
+
}
|
|
789
|
+
case 'pages': {
|
|
790
|
+
let md = header + '## Crawled Pages\n\n| URL | Status | Words | Title | Domain | Role |\n|-----|--------|-------|-------|--------|------|\n';
|
|
791
|
+
for (const r of data) {
|
|
792
|
+
md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${(r.title || '').slice(0, 50)} | ${r.domain} | ${r.role} |\n`;
|
|
793
|
+
}
|
|
794
|
+
return md;
|
|
795
|
+
}
|
|
796
|
+
case 'watch': {
|
|
797
|
+
const snap = data.snapshot || {};
|
|
798
|
+
const events = data.events || [];
|
|
799
|
+
let md = header + `## Site Watch Snapshot\n\n- Health score: ${snap.health_score ?? 'N/A'}\n- Pages: ${snap.total_pages || 0}\n- Errors: ${snap.errors_count || 0} | Warnings: ${snap.warnings_count || 0} | Notices: ${snap.notices_count || 0}\n\n`;
|
|
800
|
+
if (events.length) {
|
|
801
|
+
md += '## Events\n\n| Type | Severity | URL | Details |\n|------|----------|-----|---------|\n';
|
|
802
|
+
for (const e of events) {
|
|
803
|
+
md += `| ${e.event_type} | ${e.severity} | ${e.url} | ${(e.details || '').slice(0, 80)} |\n`;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return md;
|
|
807
|
+
}
|
|
808
|
+
case 'schemas': {
|
|
809
|
+
let md = header + '## Schema Markup\n\n| Domain | URL | Type | Name | Rating | Price |\n|--------|-----|------|------|--------|-------|\n';
|
|
810
|
+
for (const r of data) {
|
|
811
|
+
md += `| ${r.domain} | ${r.url} | ${r.schema_type} | ${(r.name || '').slice(0, 40)} | ${r.rating || ''} | ${r.price ? r.currency + r.price : ''} |\n`;
|
|
812
|
+
}
|
|
813
|
+
return md;
|
|
814
|
+
}
|
|
815
|
+
case 'headings': {
|
|
816
|
+
let md = header + '## Heading Structure\n\n| Domain | URL | Level | Text |\n|--------|-----|-------|------|\n';
|
|
817
|
+
for (const r of data.slice(0, 1000)) {
|
|
818
|
+
md += `| ${r.domain} | ${r.url} | H${r.level} | ${(r.text || '').slice(0, 80)} |\n`;
|
|
819
|
+
}
|
|
820
|
+
if (data.length > 1000) md += `\n_...and ${data.length - 1000} more rows._\n`;
|
|
821
|
+
return md;
|
|
822
|
+
}
|
|
823
|
+
case 'links': {
|
|
824
|
+
let md = header + '## Internal Links\n\n| Source | Target | Anchor |\n|--------|--------|--------|\n';
|
|
825
|
+
for (const r of data.filter(l => l.is_internal).slice(0, 1000)) {
|
|
826
|
+
md += `| ${r.source_url} | ${r.target_url} | ${(r.anchor_text || '').slice(0, 50)} |\n`;
|
|
827
|
+
}
|
|
828
|
+
if (data.length > 1000) md += `\n_...and more rows._\n`;
|
|
829
|
+
return md;
|
|
830
|
+
}
|
|
831
|
+
default: {
|
|
832
|
+
return header + '```json\n' + JSON.stringify(data, null, 2).slice(0, 10000) + '\n```\n';
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Build response based on section + format
|
|
838
|
+
const sections = section === 'all' ? SECTIONS : [section];
|
|
839
|
+
if (section !== 'all' && !SECTIONS.includes(section)) {
|
|
840
|
+
json(res, 400, { error: `Invalid section. Allowed: ${SECTIONS.join(', ')}, all` });
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (format === 'zip') {
|
|
845
|
+
// ZIP: bundle all requested sections in all formats
|
|
846
|
+
const entries = [];
|
|
847
|
+
for (const sec of sections) {
|
|
848
|
+
const data = querySection(sec);
|
|
849
|
+
const baseName = `${project}-${sec}-${dateStr}`;
|
|
850
|
+
entries.push({ name: `${baseName}.json`, content: JSON.stringify(data, null, 2) });
|
|
851
|
+
entries.push({ name: `${baseName}.md`, content: toMarkdown(sec, data, project) });
|
|
852
|
+
const csv = toCSV(data);
|
|
853
|
+
if (csv) entries.push({ name: `${baseName}.csv`, content: csv });
|
|
854
|
+
}
|
|
855
|
+
const zipBuf = createZip(entries);
|
|
856
|
+
const zipName = section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`;
|
|
857
|
+
res.writeHead(200, {
|
|
858
|
+
'Content-Type': 'application/zip',
|
|
859
|
+
'Content-Disposition': `attachment; filename="${zipName}"`,
|
|
860
|
+
'Content-Length': zipBuf.length,
|
|
861
|
+
});
|
|
862
|
+
res.end(zipBuf);
|
|
863
|
+
} else if (format === 'json') {
|
|
864
|
+
const data = querySection(sections[0]);
|
|
865
|
+
const fileName = `${project}-${sections[0]}-${dateStr}.json`;
|
|
866
|
+
const content = JSON.stringify(data, null, 2);
|
|
867
|
+
res.writeHead(200, {
|
|
868
|
+
'Content-Type': 'application/json',
|
|
869
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
870
|
+
});
|
|
871
|
+
res.end(content);
|
|
872
|
+
} else if (format === 'csv') {
|
|
873
|
+
const data = querySection(sections[0]);
|
|
874
|
+
const fileName = `${project}-${sections[0]}-${dateStr}.csv`;
|
|
875
|
+
res.writeHead(200, {
|
|
876
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
877
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
878
|
+
});
|
|
879
|
+
res.end(toCSV(data));
|
|
880
|
+
} else if (format === 'md') {
|
|
881
|
+
const data = querySection(sections[0]);
|
|
882
|
+
const fileName = `${project}-${sections[0]}-${dateStr}.md`;
|
|
883
|
+
res.writeHead(200, {
|
|
884
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
885
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
886
|
+
});
|
|
887
|
+
res.end(toMarkdown(sections[0], data, project));
|
|
888
|
+
} else {
|
|
889
|
+
json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
|
|
890
|
+
}
|
|
891
|
+
} catch (e) {
|
|
892
|
+
console.error('[export/download]', e);
|
|
893
|
+
json(res, 500, { error: e.message });
|
|
894
|
+
}
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
589
898
|
// ─── API: SSE Terminal — stream command output ───
|
|
590
899
|
if (req.method === 'GET' && path === '/api/terminal') {
|
|
591
900
|
const params = url.searchParams;
|
|
@@ -596,7 +905,7 @@ async function handleRequest(req, res) {
|
|
|
596
905
|
const ALLOWED = ['crawl', 'extract', 'analyze', 'export-actions', 'competitive-actions',
|
|
597
906
|
'suggest-usecases', 'html', 'status', 'brief', 'keywords', 'report', 'guide',
|
|
598
907
|
'schemas', 'headings-audit', 'orphans', 'entities', 'friction', 'shallow', 'decay', 'export', 'templates',
|
|
599
|
-
'aeo', 'blog-draft', 'gap-intel'];
|
|
908
|
+
'aeo', 'blog-draft', 'gap-intel', 'watch'];
|
|
600
909
|
|
|
601
910
|
if (!command || !ALLOWED.includes(command)) {
|
|
602
911
|
json(res, 400, { error: `Invalid command. Allowed: ${ALLOWED.join(', ')}` });
|
package/setup/checks.js
CHANGED
|
@@ -328,8 +328,15 @@ export function checkGscData(project) {
|
|
|
328
328
|
|
|
329
329
|
if (folders.length === 0) return { hasData: false, folders: allFolders, project };
|
|
330
330
|
|
|
331
|
-
// Check what CSV files exist in the
|
|
332
|
-
const latest = folders
|
|
331
|
+
// Check what CSV files exist in the most recently modified matching folder
|
|
332
|
+
const latest = [...folders]
|
|
333
|
+
.map(name => {
|
|
334
|
+
const folderPath = join(gscDir, name);
|
|
335
|
+
let mtimeMs = 0;
|
|
336
|
+
try { mtimeMs = statSync(folderPath).mtimeMs; } catch { /* ignore */ }
|
|
337
|
+
return { name, mtimeMs };
|
|
338
|
+
})
|
|
339
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name))[0]?.name;
|
|
333
340
|
const folderPath = join(gscDir, latest);
|
|
334
341
|
const expectedFiles = ['Chart.csv', 'Queries.csv', 'Pages.csv', 'Countries.csv', 'Devices.csv'];
|
|
335
342
|
const found = [];
|
package/setup/web-routes.js
CHANGED
|
@@ -177,6 +177,12 @@ export function handleSetupRequest(req, res, url) {
|
|
|
177
177
|
return true;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// POST /api/setup/dashboard/restart — soft restart / reload hint for dashboard UI
|
|
181
|
+
if (path === '/api/setup/dashboard/restart' && method === 'POST') {
|
|
182
|
+
handleDashboardRestart(res);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
180
186
|
// GET /api/setup/version — current version + update info
|
|
181
187
|
if (path === '/api/setup/version' && method === 'GET') {
|
|
182
188
|
handleVersion(req, res);
|
|
@@ -255,7 +261,17 @@ function serveWizardHtml(res) {
|
|
|
255
261
|
function getOllamaHosts() {
|
|
256
262
|
const hosts = [];
|
|
257
263
|
if (process.env.OLLAMA_URL) hosts.push(process.env.OLLAMA_URL);
|
|
258
|
-
|
|
264
|
+
// Support comma-separated OLLAMA_HOSTS for multiple LAN addresses
|
|
265
|
+
if (process.env.OLLAMA_HOSTS) {
|
|
266
|
+
for (const h of process.env.OLLAMA_HOSTS.split(',')) {
|
|
267
|
+
const trimmed = h.trim();
|
|
268
|
+
if (trimmed && !hosts.includes(trimmed)) hosts.push(trimmed);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Legacy single fallback
|
|
272
|
+
if (process.env.OLLAMA_FALLBACK_URL) {
|
|
273
|
+
if (!hosts.includes(process.env.OLLAMA_FALLBACK_URL)) hosts.push(process.env.OLLAMA_FALLBACK_URL);
|
|
274
|
+
}
|
|
259
275
|
return hosts;
|
|
260
276
|
}
|
|
261
277
|
|
|
@@ -394,8 +410,17 @@ async function handleEnv(req, res) {
|
|
|
394
410
|
return;
|
|
395
411
|
}
|
|
396
412
|
|
|
397
|
-
|
|
398
|
-
|
|
413
|
+
// saveModelsModule sends raw env var names (OLLAMA_MODEL, ANALYSIS_PROVIDER, etc.)
|
|
414
|
+
// while updateEnvForSetup expects camelCase. Write raw env vars directly.
|
|
415
|
+
for (const [key, value] of Object.entries(keys)) {
|
|
416
|
+
if (/^[A-Z_]+$/.test(key) && value) {
|
|
417
|
+
writeEnvKey(key, String(value));
|
|
418
|
+
process.env[key] = String(value);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const envPath = join(ROOT, '.env');
|
|
423
|
+
jsonResponse(res, { success: true, path: envPath });
|
|
399
424
|
} catch (err) {
|
|
400
425
|
jsonResponse(res, { error: err.message }, 500);
|
|
401
426
|
}
|
|
@@ -499,6 +524,15 @@ async function handleGscUpload(req, res) {
|
|
|
499
524
|
}
|
|
500
525
|
}
|
|
501
526
|
|
|
527
|
+
function handleDashboardRestart(res) {
|
|
528
|
+
jsonResponse(res, {
|
|
529
|
+
success: true,
|
|
530
|
+
restarted: true,
|
|
531
|
+
mode: 'soft',
|
|
532
|
+
message: 'Dashboard restart requested. Reload the dashboard UI to pick up latest settings.',
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
502
536
|
// ── Version / Update Handler ──────────────────────────────────────────────
|
|
503
537
|
|
|
504
538
|
async function handleVersion(req, res) {
|