// 사용자/IP 추적. 구버전 ~provider/analysis_user.jsx 의 이식. // - 기준 접속을 "같은 PC / 다른 PC / 같은 IP" 로 갈라 기간별 누적 막대로 본다 // - 목록에서 다른 사용자나 IP 를 누르면 그것을 새 기준으로 다시 분석한다 (히스토리 < 로 복귀) // 구버전은 원시 행 전체를 받아 JS 로 갈랐지만, 여기서는 기준이 바뀔 때마다 서버에 다시 묻는다. const ATRACE_COLORS = { same:"#0072B2", // 같은 PC other:"#CC79A7", // 다른 PC sameip:"#E69F00" // 같은 IP }; class AnalyticsTrace extends React.Component { constructor(props) { super(props); this.Lang = props.Lang; this.close = props.close; this.state.anchor = props.anchor; this.refRoot = React.createRef(); this.refChart = React.createRef(); } state = { anchor:null, history:[], data:null, loading:true }; async componentDidMount() { this.styleMain = R5.wStyle(` /* 화면 전체를 덮는다. 우클릭을 여기서 잡아 window 까지 올라가지 않게 하려면 패널 밖(여백)을 눌러도 이 안이어야 한다. */ #AnalyticsTrace { position:fixed; left:0; right:0; top:0; bottom:0; background-color:rgba(0,0,0,0.5); z-index:20; } #AnalyticsTrace .panel { position:absolute; left:20dp; right:20dp; top:20dp; bottom:20dp; display:flex; flex-direction:column; background:#fff; border:1px solid #888; border-radius:8dp; overflow:hidden; } #AnalyticsTrace .topbar { position:relative; flex-shrink:0; height:40dp; background:#3988ff; } #AnalyticsTrace .topbar .back { position:absolute; left:8dp; top:6dp; width:34dp; height:28dp; line-height:28dp; font-size:16dp; text-align:center; color:#fff; border:1px solid #fff; border-radius:4dp; cursor:pointer; } #AnalyticsTrace .topbar .back:hover { background:#fff; color:#3988ff; } #AnalyticsTrace .topbar .x { position:absolute; right:8dp; top:6dp; width:34dp; height:28dp; line-height:28dp; font-size:16dp; text-align:center; color:#fff; border:1px solid #fff; border-radius:4dp; cursor:pointer; } #AnalyticsTrace .topbar .x:hover { background:#f44; border-color:#f44; } #AnalyticsTrace .topbar .who { position:absolute; left:50dp; right:50dp; top:0; height:40dp; line-height:40dp; font-size:15dp; font-weight:bold; color:#fff; text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } #AnalyticsTrace .body { position:relative; flex:1; min-height:0; display:flex; flex-direction:column; overflow-x:hidden; overflow-y:auto; } /* 기본이 content-box 라 padding/border 가 너비에 더해진다. 그대로 두면 창이 좁을 때 가로로 넘친다. */ #AnalyticsTrace .tiles .tile, #AnalyticsTrace .chartbox { box-sizing:border-box; } #AnalyticsTrace canvas { max-width:100%; } #AnalyticsTrace .tiles { position:relative; flex-shrink:0; display:flex; flex-wrap:wrap; gap:10dp; padding:10dp; } #AnalyticsTrace .tiles .tile { position:relative; flex:1 1 120dp; min-width:0; padding:8dp 12dp; border:1px solid #ddd; border-radius:8dp; background:#fafafa; } #AnalyticsTrace .tiles .tile .cap { font-size:12dp; line-height:16dp; color:#666; } #AnalyticsTrace .tiles .tile .num { font-size:22dp; line-height:30dp; font-weight:bold; color:#222; } #AnalyticsTrace .tiles .tile.warn .num { color:#c00; } #AnalyticsTrace .chartbox { position:relative; flex-shrink:0; height:230dp; margin:0 10dp 10dp 10dp; padding:10dp; border:1px solid #ddd; border-radius:8dp; } #AnalyticsTrace .chartbox .canvasbox { position:relative; height:200dp; } #AnalyticsTrace .tablebox { position:relative; flex-shrink:0; margin:0 10dp 10dp 10dp; /* 6열 표는 좁은 창에서 넘친다. 잘라내지 않고 표 안에서만 가로 스크롤시킨다. */ overflow-x:auto; } #AnalyticsTrace .tablebox .cut { font-size:12dp; color:#c60; margin-bottom:4dp; } #AnalyticsTrace .listtable { width:100%; border-spacing:0; border:1px solid #888; } #AnalyticsTrace .listtable th { font-size:12dp; line-height:14dp; padding:4dp 0; border-right:1px solid #ccc; border-bottom:1px solid #888; background:#ddd; } #AnalyticsTrace .listtable td { height:26dp; text-align:center; font-size:13dp; padding:0 5dp; border-right:1px solid #fff; border-bottom:1px solid #fff; } #AnalyticsTrace .listtable tr.c1 td { background:rgba(0,114,178,0.14); } #AnalyticsTrace .listtable tr.c2 td { background:rgba(204,121,167,0.18); } #AnalyticsTrace .listtable tr.c3 td { background:rgba(230,159,0,0.18); } #AnalyticsTrace .listtable .pnt { cursor:pointer; text-decoration:underline; color:#00a; } #AnalyticsTrace .listtable .pnt:hover { background-color:#fdd; } #AnalyticsTrace .listtable td.anon { color:#888; } #AnalyticsTrace .empty { position:relative; padding:30dp 10dp; font-size:14dp; text-align:center; color:#888; } `); // 우클릭으로 이 창만 닫는다 (X 버튼과 같은 동작). // Main 은 window 에서 contextmenu 를 받아 브레드크럼을 닫으므로, // 여기서 stopPropagation 으로 끊어야 뒤에 있는 접속 목록까지 닫히지 않는다. // window 가 아니라 이 요소에 직접 걸어야 끊는 지점이 확실하다. if (this.refRoot.current != null) this.refRoot.current.addEventListener("contextmenu", this.onContextMenu); this.downloadData(); } componentWillUnmount() { if (this.refRoot.current != null) this.refRoot.current.removeEventListener("contextmenu", this.onContextMenu); this.destroyChart(); if (this.styleMain != null) { this.styleMain.destroy(); this.styleMain = null; } } // removeEventListener 로 떼려면 같은 함수 참조여야 하므로 필드로 둔다 onContextMenu = (e)=>{ e.preventDefault(); e.stopPropagation(); this.close(); }; componentDidUpdate() { this.drawChart(); } downloadData() { let a = this.state.anchor; if (a == null) return; this.setState({ loading:true }); let fd = new FormData(); fd.append("unit", this.props.unit); if (this.props.did != null && this.props.did != "") fd.append("did", this.props.did); fd.append("mid", a.mid); fd.append("umuid", a.umuid == null ? "" : a.umuid); fd.append("ip", a.ip == null ? "" : a.ip); axios.post('/api/stats/trace/', fd) .then(res=>{ if (res.data.code != 0){ alert(res.data.msg); this.setState({ loading:false }); return } this.setState({ data:res.data, loading:false }); }) .catch(e=>{ this.setState({ loading:false }); }); } destroyChart() { if (this.chart != null) { this.chart.destroy(); this.chart = null; } this.chartSign = null; } drawChart() { if (typeof Chart == "undefined") return; let cvs = this.refChart.current; let data = this.state.data; if (cvs == null || data == null || data.periods == null || data.periods.length == 0) { this.destroyChart(); return; } let a = this.state.anchor; let sign = a.mid+"/"+a.umuid+"/"+a.ip+"/"+data.periods.length; if (this.chartSign == sign) return; this.destroyChart(); this.chartSign = sign; let periods = data.periods; let mkset = (label, color, values)=>{ return { label:label, data:values, backgroundColor:color, borderColor:"#fff", borderWidth:1, stack:"s" } }; this.chart = new Chart(cvs.getContext("2d"), { type:"bar", data:{ labels:periods.map(p=>p.p), datasets:[ mkset(this.Lang("t_same","an_page"), ATRACE_COLORS.same, periods.map(p=>p.same)), mkset(this.Lang("t_other","an_page"), ATRACE_COLORS.other, periods.map(p=>p.other)), mkset(this.Lang("t_sameip","an_page"), ATRACE_COLORS.sameip, periods.map(p=>p.sameip)) ] }, options:{ responsive:true, maintainAspectRatio:false, interaction:{ mode:"index", intersect:false }, plugins:{ legend:{ position:"bottom", labels:{ boxWidth:12, color:"#444" } } }, scales:{ x:{ stacked:true, grid:{ color:"#eee" }, ticks:{ color:"#666" } }, y:{ stacked:true, beginAtZero:true, grid:{ color:"#eee" }, ticks:{ color:"#666", precision:0 } } } } }); } render() { let a = this.state.anchor; let data = this.state.data; let summary = (data == null || data.summary == null) ? {} : data.summary; let rows = (data == null || data.rows == null) ? [] : data.rows; let bCut = (data != null && summary.access > rows.length); let who = (a == null) ? "" : (a.mid > 0 ? (a.name + (data != null && data.anchor != null && data.anchor.email != null ? " ("+data.anchor.email+")" : "")) : ("IP " + a.ip)); return
{this.state.history.length > 0 &&
this.back()}><
}
{who}
this.close()}>X
{this.Lang("access","an_page")}
{MakeCommaString(summary.access == null ? 0 : summary.access)}
1 ? "tile warn" : "tile"}>
{this.Lang("t_pccnt","an_page")}
{MakeCommaString(summary.machines == null ? 0 : summary.machines)}
1 ? "tile warn" : "tile"}>
{this.Lang("t_ipcnt","an_page")}
{MakeCommaString(summary.ips == null ? 0 : summary.ips)}
1 ? "tile warn" : "tile"}>
{this.Lang("t_usercnt","an_page")}
{MakeCommaString(summary.users == null ? 0 : summary.users)}
{data != null && data.periods != null && data.periods.length > 0 && }
{bCut &&
{this.Lang("recent_only","an_page").replace(/%N%/g, MakeCommaString(rows.length))}
} {rows.map((r,idx)=> {r.mid > 0 ? (this.isSameAnchor(r) ? : ) : } {r.ip != null && r.ip != "" && r.ip != a.ip ? : } )}
{this.Lang("time","an_page")} {this.Lang("user","an_page")} {this.Lang("pc","an_page")} {this.Lang("version_col","an_page")} IP {this.Lang("locale","an_page")}
{(new Date(r.time * 1000)).toLocaleString()}{r.name}this.go(r)}>{r.name}{this.Lang("anon","an_page")}{r.mname != null ? r.mname : (r.pcname != null ? r.pcname : "")} {r.lver}this.goIP(r)}>{r.ip}{r.ip}{(r.country == null ? this.Lang("unknown","an_page") : r.country) + (r.region == null ? "" : " "+r.region)}
{rows.length == 0 &&
{this.state.loading ? this.Lang("loading","an_page") : this.Lang("nodata","an_page")}
}
} // 지금 보고 있는 기준과 같은 행이면 다시 눌러도 의미가 없다 isSameAnchor(r) { let a = this.state.anchor; return (a != null && a.mid == r.mid && a.umuid == r.umuid); } pushAnchor( anchor ) { let history = this.state.history.slice(); history.push(this.state.anchor); this.setState({ history:history, anchor:anchor, data:null }, e=>this.downloadData()); } go(r) { this.pushAnchor({ mid:r.mid, umuid:(r.umuid == null ? "" : r.umuid), ip:(r.ip == null ? "" : r.ip), name:r.name }); } goIP(r) { this.pushAnchor({ mid:0, umuid:"", ip:r.ip, name:"IP "+r.ip }); } back() { let history = this.state.history.slice(); if (history.length <= 0) return; let anchor = history.pop(); this.setState({ history:history, anchor:anchor, data:null }, e=>this.downloadData()); } }