diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index a7f5e77..28b3fa3 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -513,8 +513,9 @@ def _parse_time_remaining(text): return None def _read_cached_ip(): + """Return (ip, mtime) from the most recent .ddns-last-ip-* file, or ('', None).""" try: - best_ip, best_mtime = '', 0 + best_ip, best_mtime = '', 0.0 for fname in os.listdir(CONFIGS_DIR): if fname.startswith('.ddns-last-ip-'): path = f'{CONFIGS_DIR}/{fname}' @@ -523,12 +524,12 @@ def _read_cached_ip(): ip = open(path).read().strip() if ip: best_ip, best_mtime = ip, mtime - return best_ip + return best_ip, (best_mtime if best_ip else None) except Exception: - return '' + return '', None def _public_ip_info(ddns_cfg): - """Return (ip_str, domains_sub, next_interval_str) for stat cards.""" + """Return (ip_str, domains_sub, next_interval_str, last_obtained_str) for stat cards.""" script = f'{CONFIGS_DIR}/ddns.py' enabled_p = [p for p in ddns_cfg.get('providers', []) if p.get('enabled', True)] all_hosts = [] @@ -538,6 +539,9 @@ def _public_ip_info(ddns_cfg): interval_secs = _parse_interval_to_seconds(ddns_cfg.get('general', {}).get('timer_interval', '')) next_interval = '-' + def _last_obtained(mtime): + return f'Last obtained: {_relative_time(mtime)}' if mtime else '' + # Path 1: timer healthy and within interval -> use cached IP if interval_secs and enabled_p: status = _run(f'python3 {script} --status 2>/dev/null') @@ -548,17 +552,18 @@ def _public_ip_info(ddns_cfg): if remaining is not None: next_interval = _fmt_seconds(remaining) if is_enabled and is_active and remaining is not None and remaining < interval_secs: - ip = _read_cached_ip() + ip, mtime = _read_cached_ip() if ip: - return ip, domains_sub, next_interval + return ip, domains_sub, next_interval, _last_obtained(mtime) # Path 2: live fetch ip = _run(f'python3 {script} --getip 2>/dev/null') if ip and re.match(r'^\d{1,3}(\.\d{1,3}){3}$', ip): - return ip, domains_sub, next_interval + _, mtime = _read_cached_ip() + return ip, domains_sub, next_interval, _last_obtained(mtime) # Path 3: offline - return 'DDNS Offline', domains_sub, next_interval + return 'DDNS Offline', domains_sub, next_interval, '' def _vpn_info(): for vlan in _load_core().get('vlans', []): @@ -705,10 +710,11 @@ def collect_tokens(): except Exception: tokens['VPN_GATEWAY'] = '' - ip_str, sub_str, next_interval = _public_ip_info(ddns) - tokens['STAT_PUBLIC_IP'] = ip_str - tokens['STAT_DDNS_HOSTNAME'] = sub_str - tokens['STAT_DDNS_NEXT_INTERVAL'] = next_interval + ip_str, sub_str, next_interval, last_obtained = _public_ip_info(ddns) + tokens['STAT_PUBLIC_IP'] = ip_str + tokens['STAT_DDNS_HOSTNAME'] = sub_str + tokens['STAT_DDNS_NEXT_INTERVAL'] = next_interval + tokens['STAT_PUBLIC_IP_LAST_OBTAINED'] = last_obtained tokens['DDNS_LOG_TAIL'], tokens['DDNS_LOG_SUMMARY'] = _ddns_log_tail() tokens['STAT_UPTIME'] = _run('uptime -p') or '-' tokens['STAT_NFTABLES_STATUS'] = 'Active' if _run('nft list tables 2>/dev/null').strip() else 'Inactive' @@ -1490,9 +1496,7 @@ def render_layout(view_id, content_html, tokens): sev = item.get('severity', 'error') text = e(item.get('detail', item.get('name', ''))) tip = item.get('suggestion', '') - if tip: - text += f' {e(tip)}' - grouped.setdefault(sev, []).append(text) + grouped.setdefault(sev, []).append((text, tip)) for item in st.get('services', []): if item.get('status') == 'problem': name = item.get('name', '') @@ -1510,18 +1514,29 @@ def render_layout(view_id, content_html, tokens): f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}.") tip = f"Run `sudo python3 core.py --apply` to {' and '.join(reversed(fix_parts))} it." sev = item.get('severity', 'error') - text = e(detail) - if tip: - text += f' {e(tip)}' - grouped.setdefault(sev, []).append(text) + grouped.setdefault(sev, []).append((e(detail), tip)) for sev, items in grouped.items(): if not items: continue cls = 'info-bar-danger' if sev == 'error' else 'info-bar-warning' - if len(items) == 1: - content = items[0] - else: - content = '' + seen_cmds, fix_cmds = set(), [] + for _, tip in items: + if tip: + m = re.search(r'`([^`]+)`', tip) + cmd = m.group(1) if m else tip + if cmd not in seen_cmds: + seen_cmds.add(cmd) + fix_cmds.append(cmd) + problems_list = ('') + fix_html = '' + if fix_cmds: + fix_items = ''.join(f'
  • {e(c)}
  • ' for c in fix_cmds) + fix_html = ('
    To fix:
    ' + f'') + content = ('Health check — problems found:' + + problems_list + fix_html) problem_bars += f'
    {content}
    \n' except Exception: pass diff --git a/docker/routlin-dash/data/page_content.json b/docker/routlin-dash/data/page_content.json index d0b20fd..99816cd 100644 --- a/docker/routlin-dash/data/page_content.json +++ b/docker/routlin-dash/data/page_content.json @@ -313,7 +313,7 @@ "type": "stat_card", "label": "Current Public IP", "value": "%STAT_PUBLIC_IP%", - "sub": "" + "sub": "%STAT_PUBLIC_IP_LAST_OBTAINED%" }, { "type": "stat_card",