mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
feat: M3 cluster page (LB groups + health check status) e2e-verified failover; M6 cache API + version path hardening + frontend cluster methods
This commit is contained in:
@ -233,8 +233,27 @@ func fetchFromURL(ctx context.Context, version string) ([]byte, error) {
|
||||
|
||||
// EnsureVersion makes sure a given frpc version is cached locally. It follows
|
||||
// the distribution priority: local cache -> cluster peers -> external URL.
|
||||
// pathSafeVersion normalizes a requested version and rejects anything that
|
||||
// could escape the cache dir (e.g. "../../x").
|
||||
func pathSafeVersion(version string) (string, bool) {
|
||||
v := strings.TrimPrefix(version, "v")
|
||||
if v == "" || strings.ContainsAny(v, "/\\") || strings.Contains(v, "..") {
|
||||
return "", false
|
||||
}
|
||||
for _, c := range v {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && c != '.' && c != '-' && c != '_' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func (r *Registry) EnsureVersion(ctx context.Context, version string) (string, error) {
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
safe, ok := pathSafeVersion(version)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid version %q", version)
|
||||
}
|
||||
version = safe
|
||||
if r.HasVersion(version) {
|
||||
return r.BinaryPath(version), nil
|
||||
}
|
||||
@ -403,3 +422,47 @@ func (r *Registry) SyncFromPeers(ctx context.Context) error {
|
||||
func (r *Registry) SelfInfo() NodeInfo {
|
||||
return NodeInfo{Addr: "self", Version: "", Cache: r.CachedVersions()}
|
||||
}
|
||||
|
||||
// CacheEntry describes one locally cached frpc version (for UI + prune).
|
||||
type CacheEntry struct {
|
||||
Version string `json:"version"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime int64 `json:"modTime"`
|
||||
}
|
||||
|
||||
// CacheInfo lists all locally cached frpc versions with metadata.
|
||||
func (r *Registry) CacheInfo() []CacheEntry {
|
||||
entries, err := os.ReadDir(r.BinDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []CacheEntry
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.HasPrefix(e.Name(), "frpc-") {
|
||||
continue
|
||||
}
|
||||
ver := strings.TrimPrefix(e.Name(), "frpc-")
|
||||
bin := filepath.Join(r.BinDir, e.Name(), "frpc")
|
||||
info, err := os.Stat(bin)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, CacheEntry{Version: ver, Path: bin, Size: info.Size(), ModTime: info.ModTime().Unix()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PruneCache removes all cached versions except the newest keep (LRU-ish).
|
||||
func (r *Registry) PruneCache(keep int) []string {
|
||||
entries := r.CacheInfo()
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].ModTime > entries[j].ModTime })
|
||||
var removed []string
|
||||
for i := keep; i < len(entries); i++ {
|
||||
dir := filepath.Join(r.BinDir, "frpc-"+entries[i].Version)
|
||||
if err := os.RemoveAll(dir); err == nil {
|
||||
removed = append(removed, entries[i].Version)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@ -120,3 +120,35 @@ func TestRegisterAndDiscover(t *testing.T) {
|
||||
t.Fatalf("node list = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheInfoAndPrune(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFakeFrpc(t, dir, "0.70.0")
|
||||
writeFakeFrpc(t, dir, "0.71.0")
|
||||
reg := NewRegistry(dir)
|
||||
infos := reg.CacheInfo()
|
||||
if len(infos) != 2 {
|
||||
t.Fatalf("cache info = %+v", infos)
|
||||
}
|
||||
// prune keep=1 -> removes oldest by modtime (0.70.0 likely older)
|
||||
removed := reg.PruneCache(1)
|
||||
_ = removed
|
||||
if len(reg.CacheInfo()) != 1 {
|
||||
t.Fatalf("after prune keep=1: %+v", reg.CacheInfo())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathSafeVersion(t *testing.T) {
|
||||
good := []string{"0.71.0", "v0.71.0", "1.2.3-beta", "0.71.0-rc1_amd64"}
|
||||
for _, v := range good {
|
||||
if _, ok := pathSafeVersion(v); !ok {
|
||||
t.Fatalf("expected safe: %q", v)
|
||||
}
|
||||
}
|
||||
bad := []string{"", "../evil", "a/b", "..", `x\y`, "0.7 1.0"}
|
||||
for _, v := range bad {
|
||||
if _, ok := pathSafeVersion(v); ok {
|
||||
t.Fatalf("expected unsafe: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
internal/httpapi/dist/assets/index-C04K5jB8.js
vendored
76
internal/httpapi/dist/assets/index-C04K5jB8.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
76
internal/httpapi/dist/assets/index-C9C9_j3w.js
vendored
Normal file
76
internal/httpapi/dist/assets/index-C9C9_j3w.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-C04K5jB8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B6NLA8yp.css">
|
||||
<script type="module" crossorigin src="/assets/index-C9C9_j3w.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C5T3LFGh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -89,6 +89,7 @@ func NewServeMux(h *Handler) (http.Handler, error) {
|
||||
|
||||
// M6: cluster nodes + per-node cached versions (UI + discovery).
|
||||
mux.HandleFunc(apiPrefix+"/cluster/nodes", auth(h.handleClusterNodes))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/cache", auth(h.handleClusterCache))
|
||||
|
||||
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
|
||||
// Not under /api so peers hit it directly; auth still applied.
|
||||
@ -320,6 +321,34 @@ func (h *Handler) handleFrpcBinary(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
|
||||
// handleClusterCache lists cached frpc versions (GET) or prunes (POST {keep:N}).
|
||||
func (h *Handler) handleClusterCache(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Cluster == nil {
|
||||
http.Error(w, "cluster registry not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cache": h.Cluster.CacheInfo()})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Keep int `json:"keep"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Keep < 0 || req.Keep > 50 {
|
||||
http.Error(w, "keep in [0,50]", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
removed := h.Cluster.PruneCache(req.Keep)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"removed": removed, "cache": h.Cluster.CacheInfo()})
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// frpcVersionOf extracts the frpc version from a binary path like
|
||||
// .../bin/frpc-0.71.0/frpc. Empty when not a versioned cache path.
|
||||
func frpcVersionOf(binPath string) string {
|
||||
|
||||
2
plan.md
2
plan.md
@ -59,7 +59,7 @@
|
||||
- [x] 渲染器:proxy 输出 loadBalancer + healthCheck 段;worker 配置输出 webServer 段(frpc >= 0.52 支持)
|
||||
- [x] 状态页:按 admin API GET /api/status 拉取真实 per-proxy 状态(running / check failed / wait start / start error / new / closed),未启用 admin 时回退日志推断
|
||||
- [x] 前端:LocalNode/RemoteNode 表单 + StatusView per-proxy 状态与 LB 组徽标(vue-tsc 通过)
|
||||
- [ ] CLUSTER 页:展示负载均衡组(group)+ 健康检查状态(待 M3 后续迭代)
|
||||
- [x] CLUSTER 页:展示负载均衡组(group)+ 健康检查状态(e2e 验证:backend 停止→check failed 剔除,恢复→自动收回)
|
||||
|
||||
### M4 更多代理类型
|
||||
|
||||
|
||||
@ -1,84 +1,94 @@
|
||||
// HTTP client and API functions for webui4frpc.
|
||||
import type {
|
||||
BinaryStatus,
|
||||
CacheResp,
|
||||
CanvasData,
|
||||
ClusterNodesResp,
|
||||
InstallResult,
|
||||
Remote,
|
||||
Settings,
|
||||
StatusResp,
|
||||
} from './types'
|
||||
} from "./types";
|
||||
|
||||
class HTTPError extends Error {
|
||||
status: number
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
credentials: "same-origin",
|
||||
...options,
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HTTPError(response.status, `HTTP ${response.status}`)
|
||||
throw new HTTPError(response.status, `HTTP ${response.status}`);
|
||||
}
|
||||
const ct = response.headers.get('content-type') || ''
|
||||
if (ct.includes('application/json')) {
|
||||
return response.json() as Promise<T>
|
||||
const ct = response.headers.get("content-type") || "";
|
||||
if (ct.includes("application/json")) {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
return response.text() as unknown as Promise<T>
|
||||
return response.text() as unknown as Promise<T>;
|
||||
}
|
||||
|
||||
const json = (body: unknown): RequestInit => ({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
});
|
||||
|
||||
export const api = {
|
||||
status: () => request<StatusResp>('/api/manager/status'),
|
||||
status: () => request<StatusResp>("/api/manager/status"),
|
||||
|
||||
canvas: () => request<CanvasData>('/api/manager/canvas'),
|
||||
canvas: () => request<CanvasData>("/api/manager/canvas"),
|
||||
saveCanvas: (data: CanvasData) =>
|
||||
request<CanvasData>('/api/manager/canvas', json(data)),
|
||||
request<CanvasData>("/api/manager/canvas", json(data)),
|
||||
|
||||
settings: () => request<Settings>('/api/manager/settings'),
|
||||
settings: () => request<Settings>("/api/manager/settings"),
|
||||
saveSettings: (s: Settings) =>
|
||||
request<Settings>('/api/manager/settings', json(s)),
|
||||
request<Settings>("/api/manager/settings", json(s)),
|
||||
|
||||
binaryStatus: () => request<BinaryStatus>('/api/manager/binary/status'),
|
||||
binaryStatus: () => request<BinaryStatus>("/api/manager/binary/status"),
|
||||
installBinary: (version?: string) =>
|
||||
request<InstallResult>('/api/manager/binary/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
request<InstallResult>("/api/manager/binary/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: version ? JSON.stringify({ version }) : undefined,
|
||||
}),
|
||||
|
||||
|
||||
saveRemote: (remote: Remote) =>
|
||||
request<Remote>('/api/manager/remotes', json(remote)),
|
||||
request<Remote>("/api/manager/remotes", json(remote)),
|
||||
deleteRemote: (name: string) =>
|
||||
request<void>(`/api/manager/remotes/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
profileStart: (name: string) =>
|
||||
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
}),
|
||||
profileStop: (name: string) =>
|
||||
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/stop`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
}),
|
||||
profileRestart: (name: string) =>
|
||||
request<void>(
|
||||
`/api/manager/profiles/${encodeURIComponent(name)}/restart`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/restart`, {
|
||||
method: "POST",
|
||||
}),
|
||||
profileConfig: (name: string) =>
|
||||
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/config`),
|
||||
profileLogs: (name: string) =>
|
||||
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/logs`),
|
||||
}
|
||||
|
||||
// M6: cluster nodes + binary cache management.
|
||||
clusterNodes: () => request<ClusterNodesResp>("/api/manager/cluster/nodes"),
|
||||
clusterCache: () => request<CacheResp>("/api/manager/cluster/cache"),
|
||||
pruneCache: (keep: number) =>
|
||||
request<CacheResp>("/api/manager/cluster/cache", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ keep }),
|
||||
}),
|
||||
};
|
||||
|
||||
@ -150,3 +150,26 @@ export interface InstallResult {
|
||||
path: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// M6: cluster node registry + binary cache.
|
||||
export interface ClusterNode {
|
||||
addr: string;
|
||||
cache?: string[];
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
version: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modTime: number;
|
||||
}
|
||||
|
||||
export interface ClusterNodesResp {
|
||||
nodes: ClusterNode[];
|
||||
}
|
||||
|
||||
export interface CacheResp {
|
||||
cache: CacheEntry[];
|
||||
removed?: string[];
|
||||
}
|
||||
|
||||
@ -2,18 +2,147 @@
|
||||
<div class="cluster-page">
|
||||
<div class="cluster-top">
|
||||
<h2 class="page-title">集群</h2>
|
||||
<span class="page-sub">
|
||||
负载均衡组(group)与健康检查(health check)将在这里展示 — M3 功能,占位
|
||||
</span>
|
||||
<span class="page-sub">负载均衡组 + 健康检查状态(M3)</span>
|
||||
<button class="refresh-btn" @click="load">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="cluster-body">
|
||||
<el-empty description="集群功能开发中(M3:负载均衡与健康检查)" />
|
||||
<!-- 远程节点 × LB 组概览 -->
|
||||
<section class="section" v-if="profileList.length">
|
||||
<h3 class="section-title">
|
||||
远程节点 × 负载均衡组
|
||||
<span class="hint">group 中同一 local 多成员自动负载均衡</span>
|
||||
</h3>
|
||||
<div class="node-grid">
|
||||
<div v-for="p in profileList" :key="p.name" class="node-card">
|
||||
<div class="nc-head">
|
||||
<span class="nc-name">{{ p.name }}</span>
|
||||
<span class="nc-badge" :class="connClass(p.connState)">
|
||||
{{ connLabel(p.connState) }}
|
||||
</span>
|
||||
<span v-if="p.groupCount" class="nc-lb">LB ×{{ p.groupCount }}</span>
|
||||
</div>
|
||||
<div class="nc-meta">
|
||||
<span v-if="p.adminEnabled" class="tag">admin API 状态</span>
|
||||
<span class="fwd-count">{{ p.forwards.length }} 转发</span>
|
||||
</div>
|
||||
|
||||
<!-- per-proxy 健康状态 -->
|
||||
<div v-if="p.proxyStates?.length" class="proxy-list">
|
||||
<div
|
||||
v-for="ps in p.proxyStates"
|
||||
:key="ps.name"
|
||||
class="proxy-row"
|
||||
:class="psClass(ps.status)"
|
||||
>
|
||||
<span class="pr-name">{{ ps.name }}</span>
|
||||
<span class="pr-type">{{ ps.type }}</span>
|
||||
<span class="pr-status">{{ psLabel(ps.status) }}</span>
|
||||
<span v-if="ps.remote_addr" class="pr-addr">{{ ps.remote_addr }}</span>
|
||||
<span v-if="ps.err" class="pr-err" :title="ps.err">{{ ps.err }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="proxy-empty">
|
||||
{{ p.adminEnabled ? 'admin API 未返回状态(frpc 未连上 frps)' : '未启用 admin API(回退日志推断)' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 本地服务 → LB 组成员 -->
|
||||
<section class="section" v-if="localStatusList.length">
|
||||
<h3 class="section-title">本地服务 → 转发成员</h3>
|
||||
<div class="ls-list">
|
||||
<div v-for="ls in localStatusList" :key="ls.local.name" class="ls-card">
|
||||
<div class="ls-head">
|
||||
<span class="ls-name">{{ ls.local.name }}</span>
|
||||
<span class="ls-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
|
||||
<span class="ls-proto">{{ ls.local.protocol }}</span>
|
||||
<span v-if="ls.local.lbGroup" class="ls-lb">
|
||||
group: {{ ls.local.lbGroup }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ls-targets">
|
||||
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="ls-tgt">
|
||||
→ {{ t.remote }}:{{ t.remotePort }}
|
||||
<span class="ls-state" :class="{ ok: t.workerState === 'running' }">
|
||||
{{ t.workerState }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-empty v-if="!profileList.length && !localStatusList.length" description="暂无集群配置(先在状态/连接配置页添加 remote 与 local)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// M3 集群页骨架:group / groupKey 多后端负载均衡 + 健康检查(tcp/http)将在此呈现。
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { api } from '../api'
|
||||
import type { StatusResp } from '../types'
|
||||
|
||||
const status = ref<StatusResp | null>(null)
|
||||
let timer: number | null = null
|
||||
|
||||
const profileList = computed(() => status.value?.profiles ?? [])
|
||||
const localStatusList = computed(() => status.value?.localStatus ?? [])
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
status.value = await api.status()
|
||||
} catch {
|
||||
// keep last on transient errors
|
||||
}
|
||||
}
|
||||
|
||||
const connLabel = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'connected': return '已连接'
|
||||
case 'connecting': return '连接中'
|
||||
case 'failed': return '连接失败'
|
||||
case 'disabled': return '已停用'
|
||||
default: return '未启动'
|
||||
}
|
||||
}
|
||||
const connClass = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'connected': return 'ok'
|
||||
case 'connecting': return 'pending'
|
||||
case 'failed': return 'err'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
const psLabel = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'running': return '运行中'
|
||||
case 'wait start': return '等待启动'
|
||||
case 'start error': return '启动错误'
|
||||
case 'check failed': return '健康检查失败'
|
||||
case 'closed': return '已关闭'
|
||||
default: return '新建'
|
||||
}
|
||||
}
|
||||
const psClass = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'running': return 'ok'
|
||||
case 'check failed':
|
||||
case 'start error': return 'err'
|
||||
case 'wait start': return 'pending'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = window.setInterval(load, 8000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) { clearInterval(timer); timer = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -30,16 +159,72 @@
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.page-sub {
|
||||
color: $color-text-muted;
|
||||
.page-title { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
.page-sub { color: $color-text-muted; font-size: 13px; }
|
||||
.refresh-btn {
|
||||
margin-left: auto;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 6px;
|
||||
padding: 3px 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
&:hover { background: $color-bg-hover; }
|
||||
}
|
||||
.cluster-body {
|
||||
flex: 1;
|
||||
.section { margin-bottom: 20px; }
|
||||
.section-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
.hint { font-size: 12px; font-weight: 400; color: $color-text-muted; margin-left: 8px; }
|
||||
}
|
||||
.node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.node-card {
|
||||
border: 1px solid $color-border-light;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
}
|
||||
.nc-head { display: flex; align-items: center; gap: 8px; }
|
||||
.nc-name { font-weight: 600; }
|
||||
.nc-badge { font-size: 12px; padding: 1px 8px; border-radius: 8px; }
|
||||
.nc-badge.ok { background: #f0f9eb; color: $color-success; }
|
||||
.nc-badge.pending { background: #ecf5ff; color: #409eff; }
|
||||
.nc-badge.err { background: #fef0f0; color: $color-danger; }
|
||||
.nc-badge:not(.ok):not(.pending):not(.err) { background: #f2f3f5; color: $color-text-muted; }
|
||||
.nc-lb { margin-left: auto; font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 10px; padding: 1px 8px; }
|
||||
.nc-meta { margin: 6px 0 8px; display: flex; gap: 8px; align-items: center; }
|
||||
.tag { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
|
||||
.fwd-count { font-size: 12px; color: $color-text-muted; }
|
||||
.proxy-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.proxy-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 12px; border: 1px solid $color-border-lighter;
|
||||
border-radius: 8px; padding: 3px 8px;
|
||||
}
|
||||
.proxy-row.ok { border-left: 3px solid $color-success; }
|
||||
.proxy-row.pending { border-left: 3px solid #409eff; }
|
||||
.proxy-row.err { border-left: 3px solid $color-danger; }
|
||||
.pr-name { font-weight: 600; }
|
||||
.pr-type { font-size: 11px; color: $color-text-muted; }
|
||||
.pr-status { color: $color-text-secondary; }
|
||||
.pr-addr { margin-left: auto; font-size: 11px; color: $color-text-muted; }
|
||||
.pr-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.proxy-empty { font-size: 12px; color: $color-text-muted; }
|
||||
.ls-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.ls-card { border: 1px solid $color-border-light; border-radius: 10px; padding: 10px 14px; background: #fafafa; }
|
||||
.ls-head { display: flex; align-items: center; gap: 8px; }
|
||||
.ls-name { font-weight: 600; }
|
||||
.ls-addr { font-size: 12px; color: $color-text-secondary; }
|
||||
.ls-proto { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
|
||||
.ls-lb { font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 6px; padding: 1px 6px; }
|
||||
.ls-targets { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.ls-tgt { font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
||||
.ls-state { margin-left: auto; font-size: 12px; color: $color-danger; }
|
||||
.ls-state.ok { color: $color-success; }
|
||||
.ls-none { color: $color-text-light; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user