@adcops/autocore-react 3.3.45 → 3.3.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/LogPanel.css +191 -0
- package/dist/components/LogPanel.d.ts +5 -0
- package/dist/components/LogPanel.d.ts.map +1 -0
- package/dist/components/LogPanel.js +1 -0
- package/dist/components/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/ResultHistoryTable.js +1 -1
- package/package.json +1 -1
- package/src/components/LogPanel.css +191 -0
- package/src/components/LogPanel.tsx +208 -0
- package/src/components/ResultHistoryTable.tsx +101 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* LogPanel Styles */
|
|
2
|
+
|
|
3
|
+
.log-panel {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
height: 250px;
|
|
7
|
+
min-height: 150px;
|
|
8
|
+
max-height: 400px;
|
|
9
|
+
background-color: var(--surface-0, #0d0d0d);
|
|
10
|
+
border-top: 1px solid var(--surface-200, #333);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.log-header {
|
|
14
|
+
display: flex;
|
|
15
|
+
justify-content: space-between;
|
|
16
|
+
align-items: center;
|
|
17
|
+
padding: 0.5rem 1rem;
|
|
18
|
+
background-color: var(--surface-50, #1a1a1a);
|
|
19
|
+
border-bottom: 1px solid var(--surface-200, #333);
|
|
20
|
+
font-size: 0.85rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.log-title {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: 0.5rem;
|
|
27
|
+
font-weight: 600;
|
|
28
|
+
color: #ccc;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.log-status {
|
|
32
|
+
font-weight: normal;
|
|
33
|
+
font-size: 0.75rem;
|
|
34
|
+
color: #888;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.log-controls {
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 1rem;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.log-control-label {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 0.4rem;
|
|
47
|
+
color: #888;
|
|
48
|
+
font-size: 0.8rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.log-control-label input[type="checkbox"] {
|
|
52
|
+
margin: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.log-level-select {
|
|
56
|
+
padding: 0.2rem 0.4rem;
|
|
57
|
+
background-color: #2a2a2a;
|
|
58
|
+
border: 1px solid #444;
|
|
59
|
+
color: #ddd;
|
|
60
|
+
font-size: 0.8rem;
|
|
61
|
+
border-radius: 2px;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.log-level-select:focus {
|
|
66
|
+
outline: none;
|
|
67
|
+
border-color: var(--primary-color, #6a9fb5);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.log-clear-btn {
|
|
71
|
+
padding: 0.2rem 0.5rem;
|
|
72
|
+
font-size: 0.75rem;
|
|
73
|
+
background-color: transparent;
|
|
74
|
+
color: #666;
|
|
75
|
+
border: 1px solid #444;
|
|
76
|
+
border-radius: 2px;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.log-clear-btn:hover {
|
|
81
|
+
background-color: #333;
|
|
82
|
+
color: #aaa;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.log-entries {
|
|
86
|
+
flex: 1;
|
|
87
|
+
overflow-y: auto;
|
|
88
|
+
padding: 0.25rem 0.5rem;
|
|
89
|
+
font-family: 'Consolas', 'Monaco', monospace;
|
|
90
|
+
font-size: 0.8rem;
|
|
91
|
+
line-height: 1.4;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.log-empty {
|
|
95
|
+
color: #555;
|
|
96
|
+
padding: 1rem;
|
|
97
|
+
text-align: center;
|
|
98
|
+
font-style: italic;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.log-entry {
|
|
102
|
+
display: flex;
|
|
103
|
+
gap: 0.5rem;
|
|
104
|
+
padding: 0.15rem 0;
|
|
105
|
+
white-space: nowrap;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.log-entry:hover {
|
|
109
|
+
background-color: rgba(255, 255, 255, 0.03);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.log-time {
|
|
113
|
+
color: #666;
|
|
114
|
+
flex-shrink: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.log-level {
|
|
118
|
+
flex-shrink: 0;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
min-width: 45px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.log-source {
|
|
124
|
+
color: #6a9fb5;
|
|
125
|
+
flex-shrink: 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.log-message {
|
|
129
|
+
color: #ccc;
|
|
130
|
+
white-space: pre-wrap;
|
|
131
|
+
word-break: break-word;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Level-specific colors */
|
|
135
|
+
.log-error {
|
|
136
|
+
color: #f44336;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.log-error .log-message {
|
|
140
|
+
color: #ff6b6b;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.log-warn {
|
|
144
|
+
color: #ff9800;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.log-warn .log-message {
|
|
148
|
+
color: #ffb74d;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.log-info {
|
|
152
|
+
color: #4caf50;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.log-info .log-message {
|
|
156
|
+
color: #ccc;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.log-debug {
|
|
160
|
+
color: #9e9e9e;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.log-debug .log-message {
|
|
164
|
+
color: #999;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.log-trace {
|
|
168
|
+
color: #666;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.log-trace .log-message {
|
|
172
|
+
color: #777;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* Scrollbar styling */
|
|
176
|
+
.log-entries::-webkit-scrollbar {
|
|
177
|
+
width: 8px;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.log-entries::-webkit-scrollbar-track {
|
|
181
|
+
background: #1a1a1a;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.log-entries::-webkit-scrollbar-thumb {
|
|
185
|
+
background: #444;
|
|
186
|
+
border-radius: 4px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.log-entries::-webkit-scrollbar-thumb:hover {
|
|
190
|
+
background: #555;
|
|
191
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LogPanel.d.ts","sourceRoot":"","sources":["../../src/components/LogPanel.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA+D,MAAM,OAAO,CAAC;AAEpF,OAAO,gBAAgB,CAAC;AA+CxB,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,EA4J5B,CAAC;AAEF,eAAe,QAAQ,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{jsx as _jsx,jsxs as _jsxs}from"react/jsx-runtime";import React,{useState,useEffect,useContext,useRef,useCallback}from"react";import{EventEmitterContext}from"../core/EventEmitterContext";import"./LogPanel.css";const LOG_LEVELS=["TRACE","DEBUG","INFO","WARN","ERROR"],MAX_LOGS=500,FLUSH_INTERVAL_MS=100,levelValue=e=>{const t=LOG_LEVELS.indexOf(e.toUpperCase());return t>=0?t:2},formatTime=e=>{const t=new Date(e);return`${t.getHours().toString().padStart(2,"0")}:${t.getMinutes().toString().padStart(2,"0")}:${t.getSeconds().toString().padStart(2,"0")}.${t.getMilliseconds().toString().padStart(3,"0")}`},getLevelClass=e=>{switch(e.toUpperCase()){case"ERROR":return"log-error";case"WARN":return"log-warn";case"INFO":default:return"log-info";case"DEBUG":return"log-debug";case"TRACE":return"log-trace"}};export const LogPanel=()=>{const{subscribe:e,unsubscribe:t,serverSubscribe:s,read:l,isConnected:r}=useContext(EventEmitterContext),[a,c]=useState([]),[n,o]=useState("INFO"),[i,u]=useState(!0),[g,d]=useState(!1),m=useRef(null),h=useRef([]),p=useRef(async()=>{});p.current=useCallback(async()=>{try{await s("log.stream","log.stream");const e=await l("log.get_buffer",{level:"TRACE"});e?.success&&Array.isArray(e.data)&&c(e.data.slice(-500)),d(!0)}catch(e){}},[s,l]),useEffect(()=>{r()&&p.current();const s=e("HUB/connected",()=>{p.current()});return()=>t(s)},[r,e,t]),useEffect(()=>{let s=!1;const l=e("log.stream",e=>{h.current.push(e),s||(s=!0,d(!0))}),r=window.setInterval(()=>{if(0===h.current.length)return;const e=h.current;h.current=[],c(t=>t.length+e.length>500?t.concat(e).slice(-500):t.concat(e))},100);return()=>{t(l),window.clearInterval(r),h.current=[]}},[e,t]),useEffect(()=>{i&&m.current&&(m.current.scrollTop=m.current.scrollHeight)},[a,i]);const x=a.filter(e=>levelValue(e.level)>=levelValue(n));return _jsxs("div",{className:"log-panel",children:[_jsxs("div",{className:"log-header",children:[_jsxs("div",{className:"log-title",children:[_jsx("span",{children:"Logs"}),!g&&_jsx("span",{className:"log-status",children:"(connecting...)"})]}),_jsxs("div",{className:"log-controls",children:[_jsxs("label",{className:"log-control-label",children:["Level:",_jsx("select",{value:n,onChange:e=>o(e.target.value),className:"log-level-select",children:LOG_LEVELS.map(e=>_jsx("option",{value:e,children:e},e))})]}),_jsxs("label",{className:"log-control-label",children:[_jsx("input",{type:"checkbox",checked:i,onChange:e=>u(e.target.checked)}),"Auto-scroll"]}),_jsx("button",{className:"log-clear-btn",onClick:async()=>{h.current=[],c([]);try{await l("log.clear")}catch(e){}},children:"Clear"})]})]}),_jsx("div",{className:"log-entries",ref:m,onScroll:()=>{if(m.current){const{scrollTop:e,scrollHeight:t,clientHeight:s}=m.current;u(t-e-s<50)}},children:0===x.length?_jsx("div",{className:"log-empty",children:"No log entries. Waiting for control program logs..."}):x.map((e,t)=>_jsxs("div",{className:`log-entry ${getLevelClass(e.level)}`,children:[_jsx("span",{className:"log-time",children:formatTime(e.timestamp_ms)}),_jsx("span",{className:`log-level ${getLevelClass(e.level)}`,children:e.level.padEnd(5)}),_jsxs("span",{className:"log-source",children:["[",e.source,"]"]}),_jsx("span",{className:"log-message",children:e.message})]},`${e.timestamp_ms}-${t}`))})]})};export default LogPanel;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ResultHistoryTable.d.ts","sourceRoot":"","sources":["../../src/components/ResultHistoryTable.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA0C,MAAM,OAAO,CAAC;AAO/D,MAAM,WAAW,uBAAuB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACxB;
|
|
1
|
+
{"version":3,"file":"ResultHistoryTable.d.ts","sourceRoot":"","sources":["../../src/components/ResultHistoryTable.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA0C,MAAM,OAAO,CAAC;AAO/D,MAAM,WAAW,uBAAuB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACxB;AAqDD,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,EAAE,CAAC,uBAAuB,CAkGhE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{jsxs as _jsxs,jsx as _jsx}from"react/jsx-runtime";import React,{useState,useEffect,useContext}from"react";import{DataTable}from"primereact/datatable";import{Column}from"primereact/column";import{Button}from"primereact/button";import{EventEmitterContext}from"../core/EventEmitterContext";import{MessageType}from"../hub/CommandMessage";export const ResultHistoryTable=({projectId:e,definitionId:t})=>{const[s,r]=useState([]),[
|
|
1
|
+
import{jsxs as _jsxs,jsx as _jsx}from"react/jsx-runtime";import React,{useState,useEffect,useContext}from"react";import{DataTable}from"primereact/datatable";import{Column}from"primereact/column";import{Button}from"primereact/button";import{EventEmitterContext}from"../core/EventEmitterContext";import{MessageType}from"../hub/CommandMessage";const rawBlobToCsv=e=>{if(!e||"object"!=typeof e)return"";const t=Object.entries(e).filter(([,e])=>Array.isArray(e));if(0===t.length)return"";t.sort(([e],[t])=>"t"===e?-1:"t"===t?1:0);const s=t.map(([e])=>e),r=t.reduce((e,[,t])=>Math.min(e,t.length),1/0),o=e=>{if(null==e)return"";const t=String(e);return/[",\n\r]/.test(t)?`"${t.replace(/"/g,'""')}"`:t},n=[s.join(",")];for(let e=0;e<r;e++)n.push(t.map(([,t])=>o(t[e])).join(","));return n.join("\n")},downloadCsv=(e,t)=>{const s=new Blob([t],{type:"text/csv;charset=utf-8;"}),r=URL.createObjectURL(s),o=document.createElement("a");o.href=r,o.download=e,document.body.appendChild(o),o.click(),o.remove(),URL.revokeObjectURL(r)};export const ResultHistoryTable=({projectId:e,definitionId:t})=>{const[s,r]=useState([]),[o,n]=useState(!1),[a,i]=useState(null),{invoke:l}=useContext(EventEmitterContext),c=async()=>{n(!0);try{const s=await l("results.list_tests",MessageType.Request,{project_id:e,definition_id:t});s.success&&s.data&&s.data.tests&&r(s.data.tests)}catch(e){}n(!1)};useEffect(()=>{c()},[e,t]);return _jsxs("div",{children:[_jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"1rem"},children:[_jsxs("h3",{children:["Test History: ",t]}),_jsx(Button,{icon:"pi pi-refresh",label:"Refresh",onClick:c,disabled:o})]}),_jsxs(DataTable,{value:s,loading:o,paginator:!0,rows:10,emptyMessage:"No tests found.",children:[_jsx(Column,{field:"run_id",header:"Run ID",sortable:!0}),_jsx(Column,{field:"start_time",header:"Date/Time",body:e=>{return(t=e.start_time)?new Date(t).toLocaleString():"";var t},sortable:!0}),_jsx(Column,{header:"Config / Results",body:e=>_jsxs("div",{style:{fontSize:"0.85em",color:"var(--text-secondary-color)"},children:[_jsxs("div",{children:[_jsx("strong",{children:"Config:"})," ",JSON.stringify(e.config)]}),_jsxs("div",{children:[_jsx("strong",{children:"Results:"})," ",JSON.stringify(e.results)]})]})}),_jsx(Column,{header:"Raw Data",style:{width:"8rem"},body:s=>_jsx(Button,{icon:a===s.run_id?"pi pi-spin pi-spinner":"pi pi-download",label:"CSV",size:"small",outlined:!0,disabled:null!==a,onClick:()=>(async s=>{const r=s?.run_id;if(r){i(r);try{const s=await l("results.read_raw",MessageType.Request,{project_id:e,definition_id:t,run_id:r,name:"trace"});if(!s?.success||!s.data)return void alert(`No raw trace available for ${r}${s?.error_message?`: ${s.error_message}`:""}`);const o=rawBlobToCsv(s.data);if(!o)return void alert(`Raw trace for ${r} is empty or has no array columns.`);downloadCsv(`${e}_${t}_${r}.csv`,o)}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{i(null)}}})(s),tooltip:"Download raw_data/trace.json as CSV",tooltipOptions:{position:"left"}})})]})]})};
|
package/package.json
CHANGED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* LogPanel Styles */
|
|
2
|
+
|
|
3
|
+
.log-panel {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
height: 250px;
|
|
7
|
+
min-height: 150px;
|
|
8
|
+
max-height: 400px;
|
|
9
|
+
background-color: var(--surface-0, #0d0d0d);
|
|
10
|
+
border-top: 1px solid var(--surface-200, #333);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.log-header {
|
|
14
|
+
display: flex;
|
|
15
|
+
justify-content: space-between;
|
|
16
|
+
align-items: center;
|
|
17
|
+
padding: 0.5rem 1rem;
|
|
18
|
+
background-color: var(--surface-50, #1a1a1a);
|
|
19
|
+
border-bottom: 1px solid var(--surface-200, #333);
|
|
20
|
+
font-size: 0.85rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.log-title {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: 0.5rem;
|
|
27
|
+
font-weight: 600;
|
|
28
|
+
color: #ccc;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.log-status {
|
|
32
|
+
font-weight: normal;
|
|
33
|
+
font-size: 0.75rem;
|
|
34
|
+
color: #888;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.log-controls {
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 1rem;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.log-control-label {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 0.4rem;
|
|
47
|
+
color: #888;
|
|
48
|
+
font-size: 0.8rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.log-control-label input[type="checkbox"] {
|
|
52
|
+
margin: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.log-level-select {
|
|
56
|
+
padding: 0.2rem 0.4rem;
|
|
57
|
+
background-color: #2a2a2a;
|
|
58
|
+
border: 1px solid #444;
|
|
59
|
+
color: #ddd;
|
|
60
|
+
font-size: 0.8rem;
|
|
61
|
+
border-radius: 2px;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.log-level-select:focus {
|
|
66
|
+
outline: none;
|
|
67
|
+
border-color: var(--primary-color, #6a9fb5);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.log-clear-btn {
|
|
71
|
+
padding: 0.2rem 0.5rem;
|
|
72
|
+
font-size: 0.75rem;
|
|
73
|
+
background-color: transparent;
|
|
74
|
+
color: #666;
|
|
75
|
+
border: 1px solid #444;
|
|
76
|
+
border-radius: 2px;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.log-clear-btn:hover {
|
|
81
|
+
background-color: #333;
|
|
82
|
+
color: #aaa;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.log-entries {
|
|
86
|
+
flex: 1;
|
|
87
|
+
overflow-y: auto;
|
|
88
|
+
padding: 0.25rem 0.5rem;
|
|
89
|
+
font-family: 'Consolas', 'Monaco', monospace;
|
|
90
|
+
font-size: 0.8rem;
|
|
91
|
+
line-height: 1.4;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.log-empty {
|
|
95
|
+
color: #555;
|
|
96
|
+
padding: 1rem;
|
|
97
|
+
text-align: center;
|
|
98
|
+
font-style: italic;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.log-entry {
|
|
102
|
+
display: flex;
|
|
103
|
+
gap: 0.5rem;
|
|
104
|
+
padding: 0.15rem 0;
|
|
105
|
+
white-space: nowrap;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.log-entry:hover {
|
|
109
|
+
background-color: rgba(255, 255, 255, 0.03);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.log-time {
|
|
113
|
+
color: #666;
|
|
114
|
+
flex-shrink: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.log-level {
|
|
118
|
+
flex-shrink: 0;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
min-width: 45px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.log-source {
|
|
124
|
+
color: #6a9fb5;
|
|
125
|
+
flex-shrink: 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.log-message {
|
|
129
|
+
color: #ccc;
|
|
130
|
+
white-space: pre-wrap;
|
|
131
|
+
word-break: break-word;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Level-specific colors */
|
|
135
|
+
.log-error {
|
|
136
|
+
color: #f44336;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.log-error .log-message {
|
|
140
|
+
color: #ff6b6b;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.log-warn {
|
|
144
|
+
color: #ff9800;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.log-warn .log-message {
|
|
148
|
+
color: #ffb74d;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.log-info {
|
|
152
|
+
color: #4caf50;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.log-info .log-message {
|
|
156
|
+
color: #ccc;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.log-debug {
|
|
160
|
+
color: #9e9e9e;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.log-debug .log-message {
|
|
164
|
+
color: #999;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.log-trace {
|
|
168
|
+
color: #666;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.log-trace .log-message {
|
|
172
|
+
color: #777;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* Scrollbar styling */
|
|
176
|
+
.log-entries::-webkit-scrollbar {
|
|
177
|
+
width: 8px;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.log-entries::-webkit-scrollbar-track {
|
|
181
|
+
background: #1a1a1a;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.log-entries::-webkit-scrollbar-thumb {
|
|
185
|
+
background: #444;
|
|
186
|
+
border-radius: 4px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.log-entries::-webkit-scrollbar-thumb:hover {
|
|
190
|
+
background: #555;
|
|
191
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import React, { useState, useEffect, useContext, useRef, useCallback } from 'react';
|
|
2
|
+
import { EventEmitterContext } from '../core/EventEmitterContext';
|
|
3
|
+
import './LogPanel.css';
|
|
4
|
+
|
|
5
|
+
interface LogEntry {
|
|
6
|
+
timestamp_ms: number;
|
|
7
|
+
level: string;
|
|
8
|
+
source: string;
|
|
9
|
+
target: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'];
|
|
14
|
+
|
|
15
|
+
// Cap on the in-memory ring buffer. Beyond this, the oldest entries drop.
|
|
16
|
+
// 500 keeps React's list small enough to reconcile without jank in Firefox
|
|
17
|
+
// (Chrome tolerates much more, but we'd rather not tune per-browser).
|
|
18
|
+
const MAX_LOGS = 500;
|
|
19
|
+
|
|
20
|
+
// Max cadence at which we push buffered entries into React state. At high log
|
|
21
|
+
// rates this turns N setState/sec into ~10 setState/sec — the difference
|
|
22
|
+
// between "Firefox stops responding" and "scrolls smoothly."
|
|
23
|
+
const FLUSH_INTERVAL_MS = 100;
|
|
24
|
+
|
|
25
|
+
const levelValue = (level: string): number => {
|
|
26
|
+
const idx = LOG_LEVELS.indexOf(level.toUpperCase());
|
|
27
|
+
return idx >= 0 ? idx : 2; // Default to INFO
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const formatTime = (timestampMs: number): string => {
|
|
31
|
+
const date = new Date(timestampMs);
|
|
32
|
+
const hours = date.getHours().toString().padStart(2, '0');
|
|
33
|
+
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
34
|
+
const seconds = date.getSeconds().toString().padStart(2, '0');
|
|
35
|
+
const ms = date.getMilliseconds().toString().padStart(3, '0');
|
|
36
|
+
return `${hours}:${minutes}:${seconds}.${ms}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getLevelClass = (level: string): string => {
|
|
40
|
+
switch (level.toUpperCase()) {
|
|
41
|
+
case 'ERROR': return 'log-error';
|
|
42
|
+
case 'WARN': return 'log-warn';
|
|
43
|
+
case 'INFO': return 'log-info';
|
|
44
|
+
case 'DEBUG': return 'log-debug';
|
|
45
|
+
case 'TRACE': return 'log-trace';
|
|
46
|
+
default: return 'log-info';
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const LogPanel: React.FC = () => {
|
|
51
|
+
const { subscribe, unsubscribe, serverSubscribe, read, isConnected } = useContext(EventEmitterContext);
|
|
52
|
+
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
53
|
+
const [levelFilter, setLevelFilter] = useState<string>('INFO');
|
|
54
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
55
|
+
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
56
|
+
const logContainerRef = useRef<HTMLDivElement>(null);
|
|
57
|
+
|
|
58
|
+
// Staging buffer for incoming entries. We do *not* call setState per entry.
|
|
59
|
+
const pendingRef = useRef<LogEntry[]>([]);
|
|
60
|
+
|
|
61
|
+
// Single init path: attempt serverSubscribe + fetch buffered logs.
|
|
62
|
+
// Called on mount, and again whenever HUB/connected fires in case the
|
|
63
|
+
// hub wasn't ready the first time.
|
|
64
|
+
const initRef = useRef<() => Promise<void>>(async () => {});
|
|
65
|
+
initRef.current = useCallback(async () => {
|
|
66
|
+
try {
|
|
67
|
+
await serverSubscribe('log.stream', 'log.stream');
|
|
68
|
+
const resp = await read('log.get_buffer', { level: 'TRACE' });
|
|
69
|
+
if (resp?.success && Array.isArray(resp.data)) {
|
|
70
|
+
setLogs(resp.data.slice(-MAX_LOGS));
|
|
71
|
+
}
|
|
72
|
+
setIsSubscribed(true);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.warn('LogPanel: init deferred until hub reconnects:', err);
|
|
75
|
+
}
|
|
76
|
+
}, [serverSubscribe, read]);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (isConnected()) {
|
|
80
|
+
initRef.current();
|
|
81
|
+
}
|
|
82
|
+
// Retry once the hub comes up (covers the mount-before-connect race
|
|
83
|
+
// that left "Connecting..." stuck on even after logs started flowing).
|
|
84
|
+
const id = subscribe('HUB/connected', () => {
|
|
85
|
+
initRef.current();
|
|
86
|
+
});
|
|
87
|
+
return () => unsubscribe(id);
|
|
88
|
+
}, [isConnected, subscribe, unsubscribe]);
|
|
89
|
+
|
|
90
|
+
// Receive log entries into a ref; flush to state on an interval.
|
|
91
|
+
// Also flips `isSubscribed` on first entry received, so the header shows
|
|
92
|
+
// the real state even if serverSubscribe didn't resolve cleanly (e.g.,
|
|
93
|
+
// the server was already broadcasting to us from a prior subscription).
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
let sawEntry = false;
|
|
96
|
+
|
|
97
|
+
const subId = subscribe('log.stream', (entry: LogEntry) => {
|
|
98
|
+
pendingRef.current.push(entry);
|
|
99
|
+
if (!sawEntry) {
|
|
100
|
+
sawEntry = true;
|
|
101
|
+
setIsSubscribed(true);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const flushTimer = window.setInterval(() => {
|
|
106
|
+
if (pendingRef.current.length === 0) return;
|
|
107
|
+
const batch = pendingRef.current;
|
|
108
|
+
pendingRef.current = [];
|
|
109
|
+
setLogs(prev => {
|
|
110
|
+
const merged = prev.length + batch.length > MAX_LOGS
|
|
111
|
+
? prev.concat(batch).slice(-MAX_LOGS)
|
|
112
|
+
: prev.concat(batch);
|
|
113
|
+
return merged;
|
|
114
|
+
});
|
|
115
|
+
}, FLUSH_INTERVAL_MS);
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
unsubscribe(subId);
|
|
119
|
+
window.clearInterval(flushTimer);
|
|
120
|
+
pendingRef.current = [];
|
|
121
|
+
};
|
|
122
|
+
}, [subscribe, unsubscribe]);
|
|
123
|
+
|
|
124
|
+
// Auto-scroll to bottom when new logs arrive.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (autoScroll && logContainerRef.current) {
|
|
127
|
+
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
|
128
|
+
}
|
|
129
|
+
}, [logs, autoScroll]);
|
|
130
|
+
|
|
131
|
+
// Handle scroll to detect if user has scrolled up.
|
|
132
|
+
const handleScroll = () => {
|
|
133
|
+
if (logContainerRef.current) {
|
|
134
|
+
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
|
135
|
+
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
|
136
|
+
setAutoScroll(isAtBottom);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Clear logs locally and on server.
|
|
141
|
+
const handleClear = async () => {
|
|
142
|
+
pendingRef.current = [];
|
|
143
|
+
setLogs([]);
|
|
144
|
+
try {
|
|
145
|
+
await read('log.clear');
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.warn('Failed to clear server log buffer:', err);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const filteredLogs = logs.filter(log => levelValue(log.level) >= levelValue(levelFilter));
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className="log-panel">
|
|
155
|
+
<div className="log-header">
|
|
156
|
+
<div className="log-title">
|
|
157
|
+
<span>Logs</span>
|
|
158
|
+
{!isSubscribed && <span className="log-status">(connecting...)</span>}
|
|
159
|
+
</div>
|
|
160
|
+
<div className="log-controls">
|
|
161
|
+
<label className="log-control-label">
|
|
162
|
+
Level:
|
|
163
|
+
<select
|
|
164
|
+
value={levelFilter}
|
|
165
|
+
onChange={(e) => setLevelFilter(e.target.value)}
|
|
166
|
+
className="log-level-select"
|
|
167
|
+
>
|
|
168
|
+
{LOG_LEVELS.map(level => (
|
|
169
|
+
<option key={level} value={level}>{level}</option>
|
|
170
|
+
))}
|
|
171
|
+
</select>
|
|
172
|
+
</label>
|
|
173
|
+
<label className="log-control-label">
|
|
174
|
+
<input
|
|
175
|
+
type="checkbox"
|
|
176
|
+
checked={autoScroll}
|
|
177
|
+
onChange={(e) => setAutoScroll(e.target.checked)}
|
|
178
|
+
/>
|
|
179
|
+
Auto-scroll
|
|
180
|
+
</label>
|
|
181
|
+
<button className="log-clear-btn" onClick={handleClear}>Clear</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
<div
|
|
185
|
+
className="log-entries"
|
|
186
|
+
ref={logContainerRef}
|
|
187
|
+
onScroll={handleScroll}
|
|
188
|
+
>
|
|
189
|
+
{filteredLogs.length === 0 ? (
|
|
190
|
+
<div className="log-empty">
|
|
191
|
+
No log entries. Waiting for control program logs...
|
|
192
|
+
</div>
|
|
193
|
+
) : (
|
|
194
|
+
filteredLogs.map((log, idx) => (
|
|
195
|
+
<div key={`${log.timestamp_ms}-${idx}`} className={`log-entry ${getLevelClass(log.level)}`}>
|
|
196
|
+
<span className="log-time">{formatTime(log.timestamp_ms)}</span>
|
|
197
|
+
<span className={`log-level ${getLevelClass(log.level)}`}>{log.level.padEnd(5)}</span>
|
|
198
|
+
<span className="log-source">[{log.source}]</span>
|
|
199
|
+
<span className="log-message">{log.message}</span>
|
|
200
|
+
</div>
|
|
201
|
+
))
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export default LogPanel;
|
|
@@ -10,9 +10,61 @@ export interface ResultHistoryTableProps {
|
|
|
10
10
|
definitionId: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Convert a results `raw_data` blob (`{ colA: [...], colB: [...], ... }`) into
|
|
15
|
+
* a CSV string. Keys with scalar (non-array) values are skipped; array lengths
|
|
16
|
+
* are truncated to the shortest column so the grid is rectangular.
|
|
17
|
+
*
|
|
18
|
+
* Column order: `t` first if present (it's the canonical x-axis in every
|
|
19
|
+
* current test schema), then the remaining keys in their JSON order. Each
|
|
20
|
+
* cell is quoted only when it contains a comma, quote, or newline — matching
|
|
21
|
+
* RFC 4180.
|
|
22
|
+
*/
|
|
23
|
+
const rawBlobToCsv = (blob: any): string => {
|
|
24
|
+
if (!blob || typeof blob !== 'object') return '';
|
|
25
|
+
|
|
26
|
+
const entries: Array<[string, any[]]> = Object.entries(blob)
|
|
27
|
+
.filter(([, v]) => Array.isArray(v)) as Array<[string, any[]]>;
|
|
28
|
+
if (entries.length === 0) return '';
|
|
29
|
+
|
|
30
|
+
entries.sort(([a], [b]) => (a === 't' ? -1 : b === 't' ? 1 : 0));
|
|
31
|
+
|
|
32
|
+
const columns = entries.map(([k]) => k);
|
|
33
|
+
const nRows = entries.reduce((min, [, arr]) => Math.min(min, arr.length), Infinity);
|
|
34
|
+
|
|
35
|
+
const escape = (v: any): string => {
|
|
36
|
+
if (v === null || v === undefined) return '';
|
|
37
|
+
const s = String(v);
|
|
38
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const lines: string[] = [columns.join(',')];
|
|
42
|
+
for (let i = 0; i < nRows; i++) {
|
|
43
|
+
lines.push(entries.map(([, arr]) => escape(arr[i])).join(','));
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Browser download shim: turn a string into a transient blob URL, click it,
|
|
50
|
+
* and clean up. Works without any extra libraries.
|
|
51
|
+
*/
|
|
52
|
+
const downloadCsv = (filename: string, csv: string) => {
|
|
53
|
+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
54
|
+
const url = URL.createObjectURL(blob);
|
|
55
|
+
const a = document.createElement('a');
|
|
56
|
+
a.href = url;
|
|
57
|
+
a.download = filename;
|
|
58
|
+
document.body.appendChild(a);
|
|
59
|
+
a.click();
|
|
60
|
+
a.remove();
|
|
61
|
+
URL.revokeObjectURL(url);
|
|
62
|
+
};
|
|
63
|
+
|
|
13
64
|
export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = ({ projectId, definitionId }) => {
|
|
14
65
|
const [tests, setTests] = useState<any[]>([]);
|
|
15
66
|
const [loading, setLoading] = useState(false);
|
|
67
|
+
const [downloadingRunId, setDownloadingRunId] = useState<string | null>(null);
|
|
16
68
|
const { invoke } = useContext(EventEmitterContext);
|
|
17
69
|
|
|
18
70
|
const loadTests = async () => {
|
|
@@ -40,13 +92,45 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = ({ projectI
|
|
|
40
92
|
return new Date(dateStr).toLocaleString();
|
|
41
93
|
};
|
|
42
94
|
|
|
95
|
+
const handleDownload = async (rowData: any) => {
|
|
96
|
+
const runId = rowData?.run_id;
|
|
97
|
+
if (!runId) return;
|
|
98
|
+
setDownloadingRunId(runId);
|
|
99
|
+
try {
|
|
100
|
+
const resp: any = await invoke('results.read_raw' as any, MessageType.Request, {
|
|
101
|
+
project_id: projectId,
|
|
102
|
+
definition_id: definitionId,
|
|
103
|
+
run_id: runId,
|
|
104
|
+
name: 'trace',
|
|
105
|
+
} as any);
|
|
106
|
+
|
|
107
|
+
if (!resp?.success || !resp.data) {
|
|
108
|
+
console.warn('results.read_raw returned no data for', runId, resp?.error_message);
|
|
109
|
+
alert(`No raw trace available for ${runId}${resp?.error_message ? `: ${resp.error_message}` : ''}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const csv = rawBlobToCsv(resp.data);
|
|
114
|
+
if (!csv) {
|
|
115
|
+
alert(`Raw trace for ${runId} is empty or has no array columns.`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
downloadCsv(`${projectId}_${definitionId}_${runId}.csv`, csv);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('Failed to download raw trace', err);
|
|
121
|
+
alert(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
122
|
+
} finally {
|
|
123
|
+
setDownloadingRunId(null);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
43
127
|
return (
|
|
44
128
|
<div>
|
|
45
129
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
|
46
130
|
<h3>Test History: {definitionId}</h3>
|
|
47
131
|
<Button icon="pi pi-refresh" label="Refresh" onClick={loadTests} disabled={loading} />
|
|
48
132
|
</div>
|
|
49
|
-
|
|
133
|
+
|
|
50
134
|
<DataTable value={tests} loading={loading} paginator rows={10} emptyMessage="No tests found.">
|
|
51
135
|
<Column field="run_id" header="Run ID" sortable />
|
|
52
136
|
<Column field="start_time" header="Date/Time" body={(rowData) => formatDate(rowData.start_time)} sortable />
|
|
@@ -56,6 +140,22 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = ({ projectI
|
|
|
56
140
|
<div><strong>Results:</strong> {JSON.stringify(rowData.results)}</div>
|
|
57
141
|
</div>
|
|
58
142
|
)} />
|
|
143
|
+
<Column
|
|
144
|
+
header="Raw Data"
|
|
145
|
+
style={{ width: '8rem' }}
|
|
146
|
+
body={(rowData) => (
|
|
147
|
+
<Button
|
|
148
|
+
icon={downloadingRunId === rowData.run_id ? 'pi pi-spin pi-spinner' : 'pi pi-download'}
|
|
149
|
+
label="CSV"
|
|
150
|
+
size="small"
|
|
151
|
+
outlined
|
|
152
|
+
disabled={downloadingRunId !== null}
|
|
153
|
+
onClick={() => handleDownload(rowData)}
|
|
154
|
+
tooltip={`Download raw_data/trace.json as CSV`}
|
|
155
|
+
tooltipOptions={{ position: 'left' }}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
/>
|
|
59
159
|
</DataTable>
|
|
60
160
|
</div>
|
|
61
161
|
);
|