Dashboard: Processes module (top by CPU/memory, PID, user, threads, SIGTERM), sampled at most every 2 s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4986af5ac9
commit
ff922bda74
@@ -16,6 +16,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
@@ -125,6 +126,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots))
|
m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots))
|
||||||
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
||||||
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
||||||
|
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
|
||||||
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
||||||
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
||||||
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
||||||
@@ -922,6 +924,30 @@ func (s *Server) zfsSnapshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(204)
|
w.WriteHeader(204)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- processes ----
|
||||||
|
|
||||||
|
func (s *Server) procSignal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, err := strconv.Atoi(r.PathValue("pid"))
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 400, "bad pid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Signal string `json:"signal"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body)
|
||||||
|
sig := syscall.SIGTERM
|
||||||
|
if body.Signal == "KILL" {
|
||||||
|
sig = syscall.SIGKILL
|
||||||
|
}
|
||||||
|
if err := metrics.Signal(pid, sig); err != nil {
|
||||||
|
jsonErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("procs: sent %s to pid %d (from %s)", sig, pid, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
||||||
|
|
||||||
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ package metrics
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -95,6 +97,17 @@ type UPS struct {
|
|||||||
LastEvent string `json:"last_event,omitempty"`
|
LastEvent string `json:"last_event,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Proc struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Cmd string `json:"cmd,omitempty"`
|
||||||
|
User string `json:"user"`
|
||||||
|
CPU float64 `json:"cpu"` // percent of one core
|
||||||
|
RSS uint64 `json:"rss"` // bytes
|
||||||
|
Threads int `json:"threads"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
type Sample struct {
|
type Sample struct {
|
||||||
T int64 `json:"t"` // unix ms
|
T int64 `json:"t"` // unix ms
|
||||||
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
||||||
@@ -109,6 +122,7 @@ type Sample struct {
|
|||||||
Disks []Disk `json:"disks"`
|
Disks []Disk `json:"disks"`
|
||||||
FS []FS `json:"fs"`
|
FS []FS `json:"fs"`
|
||||||
UPS *UPS `json:"ups"`
|
UPS *UPS `json:"ups"`
|
||||||
|
Procs []Proc `json:"procs"` // top by CPU and by RSS, merged
|
||||||
Uptime float64 `json:"uptime"`
|
Uptime float64 `json:"uptime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +150,9 @@ type Collector struct {
|
|||||||
fsAt time.Time
|
fsAt time.Time
|
||||||
lastUPS *UPS
|
lastUPS *UPS
|
||||||
upsAt time.Time
|
upsAt time.Time
|
||||||
|
prevProc map[int]procTimes
|
||||||
|
procAt time.Time
|
||||||
|
lastProc []Proc
|
||||||
upsProbe func() *UPS // nil when no UPS daemon is installed
|
upsProbe func() *UPS // nil when no UPS daemon is installed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +288,11 @@ func (c *Collector) sample() {
|
|||||||
c.lastUPS, c.upsAt = c.upsProbe(), now
|
c.lastUPS, c.upsAt = c.upsProbe(), now
|
||||||
}
|
}
|
||||||
s.UPS = c.lastUPS
|
s.UPS = c.lastUPS
|
||||||
|
// Processes: walking /proc is the expensive part, so at most every 2 s regardless of interval.
|
||||||
|
if c.lastProc == nil || now.Sub(c.procAt) >= 2*time.Second {
|
||||||
|
c.lastProc, c.procAt = c.procs(now), now
|
||||||
|
}
|
||||||
|
s.Procs = c.lastProc
|
||||||
|
|
||||||
if first {
|
if first {
|
||||||
return // deltas are meaningless on the priming pass
|
return // deltas are meaningless on the priming pass
|
||||||
@@ -835,6 +857,132 @@ func upsAPC() *UPS {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- processes ----
|
||||||
|
|
||||||
|
type procTimes struct {
|
||||||
|
ticks uint64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var clkTck = float64(100) // Linux USER_HZ; sysconf(_SC_CLK_TCK) is 100 on every mainstream build
|
||||||
|
var uidNames = map[string]string{}
|
||||||
|
|
||||||
|
func uidName(uid string) string {
|
||||||
|
if n, ok := uidNames[uid]; ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
n := uid
|
||||||
|
if u, err := user.LookupId(uid); err == nil {
|
||||||
|
n = u.Username
|
||||||
|
}
|
||||||
|
uidNames[uid] = n
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// procs returns the union of the top 15 by CPU and top 15 by RSS.
|
||||||
|
func (c *Collector) procs(now time.Time) []Proc {
|
||||||
|
ents, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return []Proc{}
|
||||||
|
}
|
||||||
|
if c.prevProc == nil {
|
||||||
|
c.prevProc = map[int]procTimes{}
|
||||||
|
}
|
||||||
|
cur := map[int]procTimes{}
|
||||||
|
var all []Proc
|
||||||
|
pageSize := uint64(os.Getpagesize())
|
||||||
|
for _, e := range ents {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stat := readStr("/proc/" + e.Name() + "/stat")
|
||||||
|
// "pid (comm with spaces) state ppid ..." — comm can contain spaces and parens
|
||||||
|
close := strings.LastIndex(stat, ")")
|
||||||
|
open := strings.Index(stat, "(")
|
||||||
|
if open < 0 || close < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := stat[open+1 : close]
|
||||||
|
f := strings.Fields(stat[close+2:])
|
||||||
|
if len(f) < 22 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
utime, _ := strconv.ParseUint(f[11], 10, 64)
|
||||||
|
stime, _ := strconv.ParseUint(f[12], 10, 64)
|
||||||
|
threads, _ := strconv.Atoi(f[17])
|
||||||
|
rssPages, _ := strconv.ParseUint(f[21], 10, 64)
|
||||||
|
ticks := utime + stime
|
||||||
|
cur[pid] = procTimes{ticks: ticks, at: now}
|
||||||
|
p := Proc{PID: pid, Name: name, State: f[0], Threads: threads, RSS: rssPages * pageSize}
|
||||||
|
if prev, ok := c.prevProc[pid]; ok && prev.ticks <= ticks {
|
||||||
|
dt := now.Sub(prev.at).Seconds()
|
||||||
|
if dt > 0 {
|
||||||
|
p.CPU = round1(float64(ticks-prev.ticks) / clkTck / dt * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all = append(all, p)
|
||||||
|
}
|
||||||
|
c.prevProc = cur
|
||||||
|
// pick: top by CPU, then fill with top by RSS
|
||||||
|
sort.Slice(all, func(i, j int) bool { return all[i].CPU > all[j].CPU })
|
||||||
|
pick := map[int]bool{}
|
||||||
|
var out []Proc
|
||||||
|
for i := 0; i < len(all) && i < 15; i++ {
|
||||||
|
if all[i].CPU > 0 || i < 5 {
|
||||||
|
pick[all[i].PID] = true
|
||||||
|
out = append(out, all[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, j int) bool { return all[i].RSS > all[j].RSS })
|
||||||
|
for i := 0; i < len(all) && i < 15; i++ {
|
||||||
|
if !pick[all[i].PID] {
|
||||||
|
pick[all[i].PID] = true
|
||||||
|
out = append(out, all[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// enrich only the chosen ones (user, cmdline) — cheap because it's ≤30 reads
|
||||||
|
for i := range out {
|
||||||
|
pidS := strconv.Itoa(out[i].PID)
|
||||||
|
for _, line := range strings.Split(readStr("/proc/"+pidS+"/status"), "\n") {
|
||||||
|
if strings.HasPrefix(line, "Uid:") {
|
||||||
|
if fs := strings.Fields(line); len(fs) > 1 {
|
||||||
|
out[i].User = uidName(fs[1])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd := strings.ReplaceAll(strings.TrimRight(readStr("/proc/"+pidS+"/cmdline"), "\x00"), "\x00", " "); cmd != "" {
|
||||||
|
if len(cmd) > 160 {
|
||||||
|
cmd = cmd[:160] + "…"
|
||||||
|
}
|
||||||
|
out[i].Cmd = cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []Proc{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal sends a signal to a pid; falls back to sudo when the process isn't ours.
|
||||||
|
func Signal(pid int, sig syscall.Signal) error {
|
||||||
|
if pid <= 1 {
|
||||||
|
return errors.New("refusing to signal pid " + strconv.Itoa(pid))
|
||||||
|
}
|
||||||
|
if err := syscall.Kill(pid, sig); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, syscall.EPERM) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lookPath("sudo") {
|
||||||
|
if err := exec.Command("sudo", "-n", "kill", "-"+strconv.Itoa(int(sig)), strconv.Itoa(pid)).Run(); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
// ---- small helpers ----
|
// ---- small helpers ----
|
||||||
|
|
||||||
func readStr(p string) string {
|
func readStr(p string) string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
||||||
import Sparkline from './Sparkline'
|
import Sparkline from './Sparkline'
|
||||||
import { Loading, Spinner, toast } from './ui'
|
import { Loading, Spinner, toast } from './ui'
|
||||||
|
import { useState as useStateP } from 'react'
|
||||||
|
|
||||||
const MAX = 300
|
const MAX = 300
|
||||||
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
||||||
@@ -20,6 +21,7 @@ const MODULES: Module[] = [
|
|||||||
{ id: 'disks', label: 'Disks', kind: 'card' },
|
{ id: 'disks', label: 'Disks', kind: 'card' },
|
||||||
{ id: 'fs', label: 'Filesystems', kind: 'card' },
|
{ id: 'fs', label: 'Filesystems', kind: 'card' },
|
||||||
{ id: 'ups', label: 'UPS', kind: 'card' },
|
{ id: 'ups', label: 'UPS', kind: 'card' },
|
||||||
|
{ id: 'procs', label: 'Processes', kind: 'card' },
|
||||||
]
|
]
|
||||||
const byId = (id: string) => MODULES.find((m) => m.id === id)
|
const byId = (id: string) => MODULES.find((m) => m.id === id)
|
||||||
|
|
||||||
@@ -108,6 +110,36 @@ function Tile({ label, value, sub, children }: { label: string; value: string; s
|
|||||||
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
|
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Processes card keeps its own sort state, so it's a component rather than a plain body.
|
||||||
|
function Procs({ s, edit }: { s: Sample; edit: Edit }) {
|
||||||
|
const [by, setBy] = useStateP<'cpu' | 'rss'>('cpu')
|
||||||
|
const rows = [...s.procs].filter((p) => !edit.hiddenItems.has(`proc:${p.name}`)).sort((a, b) => (by === 'cpu' ? b.cpu - a.cpu : b.rss - a.rss)).slice(0, 12)
|
||||||
|
const kill = async (p: Sample['procs'][number]) => {
|
||||||
|
if (!window.confirm(`Send SIGTERM to ${p.name} (pid ${p.pid})?`)) return
|
||||||
|
try { await api.procSignal(p.pid, 'TERM'); toast(`SIGTERM sent to ${p.name}`) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sub-head"><h2 style={{ margin: 0 }}>Processes</h2><span className="spacer" />
|
||||||
|
<button className={'chip' + (by === 'cpu' ? ' on' : '')} onClick={() => setBy('cpu')}>CPU</button>
|
||||||
|
<button className={'chip' + (by === 'rss' ? ' on' : '')} onClick={() => setBy('rss')}>Memory</button>
|
||||||
|
</div>
|
||||||
|
<div className="procs">
|
||||||
|
{rows.map((p) => (
|
||||||
|
<div className="proc" key={p.pid} title={p.cmd || p.name}>
|
||||||
|
<span className="pname"><span>{p.name}</span><small>{p.pid} · {p.user}{p.threads > 1 ? ` · ${p.threads} thr` : ''}</small></span>
|
||||||
|
<span className="num">{p.cpu.toFixed(1)}%</span>
|
||||||
|
<span className="num muted">{fmtBytes(p.rss)}</span>
|
||||||
|
{edit.on ? <HideBtn k={`proc:${p.name}`} edit={edit} /> : <button className="hide-item" title="send SIGTERM" onClick={() => kill(p)}>✕</button>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && <div className="empty">No process data yet.</div>}
|
||||||
|
</div>
|
||||||
|
<HiddenChips prefix="proc" edit={edit} label={(k) => k} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
||||||
const shown = (key: string) => !edit.hiddenItems.has(key)
|
const shown = (key: string) => !edit.hiddenItems.has(key)
|
||||||
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
|
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
|
||||||
@@ -230,6 +262,8 @@ function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
|||||||
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
|
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
case 'procs':
|
||||||
|
return <Procs s={s} edit={edit} />
|
||||||
case 'ups':
|
case 'ups':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+3
-1
@@ -40,10 +40,11 @@ export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: num
|
|||||||
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
||||||
export interface FS { mount: string; type: string; size: number; used: number }
|
export interface FS { mount: string; type: string; size: number; used: number }
|
||||||
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
||||||
|
export interface Proc { pid: number; name: string; cmd?: string; user: string; cpu: number; rss: number; threads: number; state: string }
|
||||||
export interface Sample {
|
export interface Sample {
|
||||||
t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number]
|
t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number]
|
||||||
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
|
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
|
||||||
temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; uptime: number
|
temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; procs: Proc[]; uptime: number
|
||||||
}
|
}
|
||||||
export interface Series {
|
export interface Series {
|
||||||
t: number[]; cpu: number[]; mem: number[]
|
t: number[]; cpu: number[]; mem: number[]
|
||||||
@@ -82,6 +83,7 @@ export interface FileInfo extends FileEntry { path: string; text: boolean }
|
|||||||
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
|
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
procSignal: (pid: number, signal: 'TERM' | 'KILL') => req<void>('POST', `/api/procs/${pid}/signal`, { signal }),
|
||||||
files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`),
|
files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`),
|
||||||
fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
|
fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
|
||||||
fileRead: async (path: string, sudo: boolean): Promise<string> => {
|
fileRead: async (path: string, sudo: boolean): Promise<string> => {
|
||||||
|
|||||||
@@ -287,3 +287,11 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.path-input { width: 100%; margin: 4px 0 0; }
|
.path-input { width: 100%; margin: 4px 0 0; }
|
||||||
.files-tools .filter { width: 80px; }
|
.files-tools .filter { width: 80px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* processes */
|
||||||
|
.procs { display: flex; flex-direction: column; }
|
||||||
|
.proc { display: grid; grid-template-columns: 1fr 64px 72px 24px; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
|
.proc:last-child { border-bottom: 0; }
|
||||||
|
.pname { display: flex; flex-direction: column; min-width: 0; } .pname span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
.pname small { color: var(--muted); font-size: 11px; }
|
||||||
|
.proc .num { text-align: right; }
|
||||||
|
|||||||
Reference in New Issue
Block a user