diff --git a/CHANGELOG.md b/CHANGELOG.md index 553a058..876418a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [v1.2.5] - 2026-09-24 + +### 新增 + +- 公网 Agent 能力扩展(agent 0.11.0):新增 `GET /api/v1/certs`(VPS 证书清单——遍历证书目录、用标准库 `crypto/x509` 解析 `fullchain.pem` 的到期时间、按剩余天数排序、解析失败的单条记入 `warnings` 不中断整轮)与 `POST /api/v1/nginx/reload`(显式校验并重载 Nginx,分别回传 `nginx -t` 与 reload 的失败原因;成功后让可用性探测缓存失效,避免页面继续显示旧结论) +- 公网 Agent 页:新增「VPS 证书」卡(域名 / 剩余天数 / **Fonu 证书库** / 到期时间),与 Fonu 证书库交叉核对(含通配符匹配),宝塔自行申请的证书也能看到;剩余不足 30 天转橙、已过期转红加粗 +- 公网 Agent 页:Nginx 卡片新增「重载 Nginx」按钮,校验 / 重载失败原因(`nginx -t` 输出或 reload 的 stderr)直接显示在卡内 + +### 改进 + +- 公网 Agent 页布局:「VPS 资源」与「VPS 证书」改为并排各占半行——`dep-grid` 由 3 列改为 6 列并按 span 分配(原有三张卡仍每行三张),半宽下资源三项指标等分、平均负载与运行时长落到卡底一行,证书表列宽收紧;≤1199px 复位跨度避免 `span` 在列数变少后撑破网格 +- 应用侧对 agent 端点的版本要求提升到 **≥ 0.11.0**(旧 agent 仍按 404/405 统一降级为「请升级 Agent」提示) + +### 变更 + +- `AgentVersion` 0.10.0 → **0.11.0**;内嵌 `fonu-agent-linux-amd64`(10.0 MB)与 `-arm64`(9.3 MB)已重编,VPS 上需点「升级 Agent」才会生效 +- 版本号提升到 1.2.5(版本单一来源仍是根目录 `VERSION`) + ## [v1.2.4] - 2026-09-24 ### 新增 diff --git a/VERSION b/VERSION index e8ea05d..c813fe1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.4 +1.2.5 diff --git a/internal/agent/insights.go b/internal/agent/insights.go index 0db2b6d..00e5eb8 100644 --- a/internal/agent/insights.go +++ b/internal/agent/insights.go @@ -2,6 +2,8 @@ package agent import ( "bytes" + "crypto/x509" + "encoding/pem" "fmt" "io" "math" @@ -10,6 +12,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strconv" "strings" "time" @@ -197,6 +200,115 @@ func tailFile(path string, lines int, maxLine int) ([]string, bool, error) { return out, truncated, nil } +// vpsCertificate VPS 证书目录里的一张证书 +// 注意:证书按 /<域名>/fullchain.pem + privkey.pem 存放(与 putCertificate 一致)。 +type vpsCertificate struct { + Domain string `json:"domain"` + CertPath string `json:"cert_path"` + KeyPath string `json:"key_path"` + NotAfter string `json:"not_after,omitempty"` + DaysLeft int `json:"days_left"` +} + +// certs 列出 VPS 证书目录下的域名与到期时间:用于与 Fonu 证书库交叉核对,也能看到宝塔自行申请的证书 +func (s *Server) certs(w http.ResponseWriter, _ *http.Request) { + dir := strings.TrimSpace(s.cfg.CertsDir) + payload := map[string]any{ + "certs": []vpsCertificate{}, "warnings": []string{}, "dir": dir, + "checked_at": time.Now().UTC().Format(time.RFC3339), + } + if dir == "" { + writeJSON(w, http.StatusOK, payload) + return + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, payload) + return + } + status := http.StatusInternalServerError + if os.IsPermission(err) { + status = http.StatusForbidden + } + writeJSON(w, status, map[string]string{"error": fmt.Sprintf("读取证书目录失败:%v", err)}) + return + } + + now := time.Now() + certs := make([]vpsCertificate, 0, len(entries)) + warnings := make([]string, 0) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + domain := entry.Name() + item := vpsCertificate{ + Domain: domain, + CertPath: filepath.Join(dir, domain, "fullchain.pem"), + KeyPath: filepath.Join(dir, domain, "privkey.pem"), + } + notAfter, err := readCertificateNotAfter(item.CertPath) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s:%v", domain, err)) + } else { + item.NotAfter = notAfter.UTC().Format(time.RFC3339) + item.DaysLeft = int(notAfter.Sub(now).Hours() / 24) + } + certs = append(certs, item) + } + // 到期最快的排在前面,便于直接看出哪张该续了 + sort.Slice(certs, func(i, j int) bool { return certs[i].DaysLeft < certs[j].DaysLeft }) + + payload["certs"] = certs + payload["warnings"] = warnings + writeJSON(w, http.StatusOK, payload) +} + +// readCertificateNotAfter 解析 PEM 证书的到期时间 +func readCertificateNotAfter(path string) (time.Time, error) { + data, err := os.ReadFile(path) + if err != nil { + return time.Time{}, err + } + block, _ := pem.Decode(data) + if block == nil { + return time.Time{}, fmt.Errorf("不是有效的 PEM 证书") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return time.Time{}, err + } + return cert.NotAfter, nil +} + +// nginxReload 显式校验并重载 Nginx,把校验/重载失败的原因回传。 +// 这里不加 s.mu:validateNginx / reloadNginx 的调用方约定(见 writeRoute 注释)是外部持锁, +// 而本端点是用户显式触发的排查动作,不加锁可避免与路由写入相互阻塞。 +func (s *Server) nginxReload(w http.ResponseWriter, _ *http.Request) { + payload := map[string]any{"checked_at": time.Now().UTC().Format(time.RFC3339)} + if err := s.validateNginx(); err != nil { + payload["ok"] = false + payload["test_ok"] = false + payload["test_error"] = err.Error() + writeJSON(w, http.StatusOK, payload) + return + } + payload["test_ok"] = true + if err := s.reloadNginx(); err != nil { + payload["ok"] = false + payload["reload_ok"] = false + payload["reload_error"] = err.Error() + writeJSON(w, http.StatusOK, payload) + return + } + // 重载成功后让 Nginx 可用性探测的缓存失效,避免页面继续显示旧结论 + s.invalidateNginxProbe() + payload["ok"] = true + payload["reload_ok"] = true + writeJSON(w, http.StatusOK, payload) +} + // frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况 func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) { path := strings.TrimSpace(s.cfg.FrpsConfigPath) diff --git a/internal/agent/server.go b/internal/agent/server.go index 201e905..33eb1d0 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -34,7 +34,8 @@ import ( // 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。 // 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。 // 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。 -const AgentVersion = "0.10.0" +// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。 +const AgentVersion = "0.11.0" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -171,6 +172,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/v1/metrics", s.auth(s.metrics)) mux.HandleFunc("GET /api/v1/logs/nginx", s.auth(s.nginxLogs)) mux.HandleFunc("GET /api/v1/frps/config", s.auth(s.frpsConfig)) + mux.HandleFunc("GET /api/v1/certs", s.auth(s.certs)) + mux.HandleFunc("POST /api/v1/nginx/reload", s.auth(s.nginxReload)) return mux } diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index c9b36db..5c7bae4 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -546,6 +546,36 @@ func (h *FRPMultiHandler) AgentFRPSConfig(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, config) } +// AgentCerts 列出 VPS 证书目录下的域名与到期时间(需 agent ≥ 0.11.0) +func (h *FRPMultiHandler) AgentCerts(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") + return + } + certs, err := h.svc.AgentCerts(r.Context(), id) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, certs) +} + +// AgentNginxReload 显式校验并重载 VPS 上的 Nginx(需 agent ≥ 0.11.0) +func (h *FRPMultiHandler) AgentNginxReload(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") + return + } + result, err := h.svc.AgentNginxReload(r.Context(), id) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, result) +} + func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) { id, err := parseID(r.PathValue("id")) if err != nil { diff --git a/internal/api/router.go b/internal/api/router.go index e101dfc..b487730 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -121,6 +121,8 @@ func NewRouter(deps Deps) http.Handler { protect("GET /api/frpmulti/servers/{id}/agent/metrics", frpHandler.AgentMetrics) protect("GET /api/frpmulti/servers/{id}/agent/logs/nginx", frpHandler.AgentNginxLogs) protect("GET /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.AgentFRPSConfig) + protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts) + protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload) protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf) protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates) diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go index 7957138..e9efdad 100644 --- a/internal/frp/agent_client.go +++ b/internal/frp/agent_client.go @@ -78,7 +78,7 @@ func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) { } // agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本 -const agentMinVersionInsights = "0.10.0" +const agentMinVersionInsights = "0.11.0" // ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405) var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口") @@ -123,6 +123,24 @@ func (c *AgentClient) NginxLogs(ctx context.Context, kind string, lines int) (ma return result, nil } +// Certs 列出 VPS 证书目录下的域名与到期时间 +func (c *AgentClient) Certs(ctx context.Context) (map[string]any, error) { + var result map[string]any + if err := c.request(ctx, http.MethodGet, "/api/v1/certs", nil, &result); err != nil { + return nil, wrapEndpointMissing(err) + } + return result, nil +} + +// NginxReload 显式校验并重载 Nginx,返回校验 / 重载结果与失败原因 +func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) { + var result map[string]any + if err := c.request(ctx, http.MethodPost, "/api/v1/nginx/reload", nil, &result); err != nil { + return nil, wrapEndpointMissing(err) + } + return result, nil +} + // FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对) func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) { var result map[string]any diff --git a/internal/frp/agent_insights.go b/internal/frp/agent_insights.go index 0407635..258a741 100644 --- a/internal/frp/agent_insights.go +++ b/internal/frp/agent_insights.go @@ -32,6 +32,24 @@ func (s *Service) AgentNginxLogs(ctx context.Context, serverID int64, kind strin return client.NginxLogs(ctx, kind, lines) } +// AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Fonu 证书库交叉核对 +func (s *Service) AgentCerts(ctx context.Context, serverID int64) (map[string]any, error) { + client, ok := s.agentClient(ctx, serverID) + if !ok { + return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") + } + return client.Certs(ctx) +} + +// AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因 +func (s *Service) AgentNginxReload(ctx context.Context, serverID int64) (map[string]any, error) { + client, ok := s.agentClient(ctx, serverID) + if !ok { + return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") + } + return client.NginxReload(ctx) +} + // AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对 func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[string]any, error) { client, ok := s.agentClient(ctx, serverID) diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index dba0066..e382a07 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index 831ace0..7236bf8 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 21f7982..8b5bf2c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -290,7 +290,13 @@ export const api = { uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }), sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }), getFrpAgentStatus: (id: number) => request(`/api/frpmulti/servers/${id}/agent/status`), - getFrpAgentMetrics: (id: number) => + getFrpAgentCerts: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/certs`), + reloadFrpAgentNginx: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/nginx/reload`, { + method: 'POST', + }), + getFrpAgentMetrics: (id: number) => request(`/api/frpmulti/servers/${id}/agent/metrics`), getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) => request( diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1c148c3..deb077e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -736,6 +736,30 @@ export interface FrpRouteHealthSummary { failed_servers?: string[] } +export interface FrpAgentCertificate { + domain: string + cert_path: string + key_path: string + not_after?: string + days_left: number +} + +export interface FrpAgentCerts { + certs: FrpAgentCertificate[] + warnings?: string[] + dir?: string + checked_at: string +} + +export interface FrpAgentNginxReload { + ok: boolean + test_ok?: boolean + test_error?: string + reload_ok?: boolean + reload_error?: string + checked_at: string +} + export interface FrpAgentMetrics { cpu_percent: number mem_used_bytes: number diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index 7647664..80252fd 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -103,6 +103,30 @@

{{ metricsLoading ? '正在采集 VPS 资源…' : '暂无 VPS 资源数据' }}

+ + +
+

{{ certsError }}

+
+
+ 域名 + 剩余天数 + Fonu 证书库 + 到期时间 +
+
+ {{ item.domain }} + + {{ item.days_left < 0 ? '已过期' : `${item.days_left} 天` }} + + {{ fonuCertDomains.has(item.domain) ? '有' : '无' }} + {{ item.not_after ? formatDate(item.not_after) : '未解析' }} +
+
+

{{ certsLoading ? '正在读取 VPS 证书…' : 'VPS 证书目录为空' }}

+

{{ certWarnings.join(';') }}

+
+
@@ -135,7 +159,9 @@
{{ agentStatus?.nginx_conf_dir || '—' }}

{{ agentStatus.nginx_error }}

+

{{ reloadHint }}

+ 重载 Nginx 去「公网反代」处理
@@ -280,7 +306,7 @@ import PageHeader from '../components/PageHeader.vue' import StatusBadge from '../components/StatusBadge.vue' import { api } from '../api/client' import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types' -import { formatBytes, formatRelativeTime, formatUptime } from '../utils/format' +import { formatBytes, formatDate, formatRelativeTime, formatUptime } from '../utils/format' import { copyText } from '../utils/clipboard' // 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈" @@ -452,6 +478,7 @@ async function load() { selectedId.value = servers.value[0]?.id ?? null } if (server.value) await refreshAll({ silent: false }) + void loadFonuCerts() } catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' } } @@ -509,6 +536,69 @@ const metricsUptimeText = computed(() => agentMetrics.value?.uptime_seconds ? formatUptime(agentMetrics.value.uptime_seconds) : '—', ) +// VPS 证书清单(需 agent ≥ 0.11.0):与 Fonu 证书库交叉核对,宝塔自签的证书也能看到 +const agentCerts = ref([]) +const certWarnings = ref([]) +const certsError = ref('') +const certsLoading = ref(false) +const fonuCertDomains = ref>(new Set()) + +async function loadAgentCerts(serverID: number) { + certsLoading.value = true + try { + const result = await api.getFrpAgentCerts(serverID) + agentCerts.value = result.certs ?? [] + certWarnings.value = result.warnings ?? [] + certsError.value = '' + } catch (error) { + agentCerts.value = [] + certWarnings.value = [] + certsError.value = error instanceof Error ? error.message : 'VPS 证书读取失败' + } finally { + certsLoading.value = false + } +} + +// Fonu 证书库里的域名(含通配符),用于判断「VPS 上有但 Fonu 没有」 +async function loadFonuCerts() { + try { + const records = await api.listCertificates() + const domains = new Set() + for (const record of records ?? []) { + domains.add(record.domain) + for (const item of record.domains ?? []) domains.add(item) + } + fonuCertDomains.value = domains + } catch { + fonuCertDomains.value = new Set() + } +} + +// Nginx 显式重载(需 agent ≥ 0.11.0):把校验 / 重载失败原因直接显示出来 +const reloadingNginx = ref(false) +const reloadHint = ref('') + +async function reloadNginx() { + const target = server.value + if (!target) return + reloadingNginx.value = true + reloadHint.value = '' + try { + const result = await api.reloadFrpAgentNginx(target.id) + if (result.ok) { + reloadHint.value = 'Nginx 校验通过并已重载' + } else if (!result.test_ok) { + reloadHint.value = `Nginx 配置校验失败:${result.test_error || '未返回原因'}` + } else { + reloadHint.value = `Nginx 重载失败:${result.reload_error || '未返回原因'}` + } + } catch (error) { + reloadHint.value = error instanceof Error ? error.message : '重载失败' + } finally { + reloadingNginx.value = false + } +} + // 轮询走静默(不触碰 busy);首次加载显示 statusLoading async function refreshAll(options: { silent?: boolean } = { silent: true }) { const current = server.value @@ -520,6 +610,7 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) { statusLoaded.value = true // 资源采集单独走,不阻断状态加载 void loadMetrics(current.id) + void loadAgentCerts(current.id) } catch (error) { agentStatusOk.value = false statusLoaded.value = true @@ -656,7 +747,9 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) }) .port-item { font-family: var(--fonu-mono); font-size: 12.5px; padding: 1px 6px; border-radius: 999px; background: var(--fonu-bg-muted); color: var(--fonu-text-muted); } .port-item--on { background: var(--fonu-brand-soft); color: var(--fonu-brand-text); } -.dep-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); } +.dep-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); } +/* 前四张卡每行三张(各占 2/6),VPS 资源与 VPS 证书各占 3/6 并排 */ +.dep-grid > .section-card { grid-column: span 2; } /* 三列下卡片较窄:短字段(版本/徽标)双列并排,长路径与命令用 .field-grid__wide 整行,控制卡片高度 */ .dep-grid .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-2) var(--fonu-space-3); } /* frps 控制与 Agent 生命周期两列并排,卡片等高 */ @@ -723,6 +816,8 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) }) @media (max-width: 1199px) { .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dep-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + /* 窄屏恢复每卡一格:span 在列数变少后会撑破网格 */ + .dep-grid > .section-card, .dep-grid > .vps-resource-card { grid-column: auto; } } @media (max-width: 900px) { .dep-grid { grid-template-columns: 1fr; } @@ -736,15 +831,16 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) }) .entrance-row { flex-direction: column; align-items: flex-start; } } /* VPS 资源:占满「环境与依赖」整行,避免单独占一格显得突兀 */ -.vps-resource-card { - grid-column: 1 / -1; +/* 选择器带 .dep-grid 前缀,避免被上面的 .dep-grid > .section-card(同权重、更早)压过 */ +.dep-grid > .vps-resource-card { + grid-column: span 3; } .res-strip { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)) auto; + grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; - gap: var(--fonu-space-5); + gap: var(--fonu-space-4); } .res-row { @@ -806,17 +902,61 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) }) } .res-meta { + grid-column: 1 / -1; display: flex; - flex-direction: column; - gap: 4px; - padding-left: var(--fonu-space-4); - border-left: 1px solid var(--fonu-border); + gap: var(--fonu-space-4); + padding-top: var(--fonu-space-2); + border-top: 1px solid var(--fonu-border); font-size: 12px; color: var(--fonu-text-muted); } @media (max-width: 900px) { .res-strip { grid-template-columns: 1fr; } - .res-meta { padding-left: 0; border-left: none; } + .res-meta { flex-direction: column; gap: 4px; } +} +.cert-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cert-row { + display: grid; + grid-template-columns: minmax(0, 1.4fr) 76px 76px 92px; + align-items: center; + gap: var(--fonu-space-2); + font-size: 13px; +} + +.cert-row--head { + font-size: 12px; + color: var(--fonu-text-muted); +} + +.cert-domain { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cert-muted { + font-size: 12px; + color: var(--fonu-text-muted); +} + +.cert-days--warn { + color: var(--fonu-warning); +} + +.cert-days--error { + color: var(--fonu-error); + font-weight: 600; +} + +@media (max-width: 900px) { + .cert-row { grid-template-columns: minmax(0, 1fr) 80px; } + .cert-row span:nth-child(3), + .cert-row span:nth-child(4) { display: none; } }