@adcops/autocore-react 3.6.7 → 3.6.8
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/tis/ResultHistoryTable.css +22 -9
- package/dist/components/tis/ResultHistoryTable.d.ts +6 -0
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/package.json +1 -1
- package/src/components/tis/ResultHistoryTable.css +22 -9
- package/src/components/tis/ResultHistoryTable.tsx +244 -58
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
/* Runs excluded from cross-test aggregation are kept visible but dimmed
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/* Runs excluded from cross-test aggregation are kept visible but dimmed, so an
|
|
2
|
+
operator can see at a glance which rows don't feed the avg/min/max/stddev/
|
|
3
|
+
count results for their sample group. The row's Status column says the word
|
|
4
|
+
"Excluded" outright, and its Details dialog says what that means — the dim
|
|
5
|
+
is a scanning aid, never the only signal.
|
|
6
|
+
|
|
7
|
+
NO line-through. It used to be here and it read as "deleted": an excluded
|
|
8
|
+
run is intact, still downloadable, and one click from being counted again.
|
|
9
|
+
|
|
10
|
+
Nor is there an exception rule for the action buttons. There was one —
|
|
11
|
+
`.tis-row-excluded > td .p-button { opacity: 1; text-decoration: none }` —
|
|
12
|
+
and neither half of it could ever have worked, because both properties come
|
|
13
|
+
from the ancestor `td`:
|
|
14
|
+
|
|
15
|
+
• `opacity` on the td composites its whole subtree as one group. A
|
|
16
|
+
descendant's `opacity: 1` cannot escape an ancestor's 0.55.
|
|
17
|
+
• `text-decoration` propagates from an ancestor to its in-flow
|
|
18
|
+
descendants and cannot be cancelled by a descendant at all.
|
|
19
|
+
|
|
20
|
+
So anything that must be exempt has to be exempt at the td: put the effect
|
|
21
|
+
on the text cells, not on every cell. Kept as one rule here because 0.55
|
|
22
|
+
leaves the buttons legible and they are what the operator reaches for. */
|
|
4
23
|
.tis-row-excluded > td {
|
|
5
24
|
opacity: 0.55;
|
|
6
|
-
text-decoration: line-through;
|
|
7
|
-
}
|
|
8
|
-
/* Keep the action buttons (Include / Download) legible and clickable. */
|
|
9
|
-
.tis-row-excluded > td .p-button {
|
|
10
|
-
opacity: 1;
|
|
11
|
-
text-decoration: none;
|
|
12
25
|
}
|
|
@@ -16,5 +16,11 @@ export interface ResultHistoryTableProps {
|
|
|
16
16
|
*/
|
|
17
17
|
methodId?: string;
|
|
18
18
|
}
|
|
19
|
+
type ChipSeverity = 'success' | 'info' | 'warning' | 'danger' | 'secondary' | undefined;
|
|
20
|
+
export interface StateChip {
|
|
21
|
+
label: string;
|
|
22
|
+
severity: ChipSeverity;
|
|
23
|
+
}
|
|
19
24
|
export declare const ResultHistoryTable: React.FC<ResultHistoryTableProps>;
|
|
25
|
+
export {};
|
|
20
26
|
//# sourceMappingURL=ResultHistoryTable.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ResultHistoryTable.d.ts","sourceRoot":"","sources":["../../../src/components/tis/ResultHistoryTable.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAkD,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"ResultHistoryTable.d.ts","sourceRoot":"","sources":["../../../src/components/tis/ResultHistoryTable.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAkD,MAAM,OAAO,CAAC;AAavE,OAAO,0BAA0B,CAAC;AAElC,MAAM,WAAW,uBAAuB;IACpC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AA8DD,KAAK,YAAY,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;AACxF,MAAM,WAAW,SAAS;IAAG,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,YAAY,CAAA;CAAE;AAkNpE,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,EAAE,CAAC,uBAAuB,CAyoBhE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{jsx as _jsx,jsxs as _jsxs}from"react/jsx-runtime";import React,{useState,useEffect,useContext,useRef}from"react";import{DataTable}from"primereact/datatable";import{Column}from"primereact/column";import{Button}from"primereact/button";import{Badge}from"primereact/badge";import{Menu}from"primereact/menu";import{Toast}from"primereact/toast";import{confirmDialog}from"primereact/confirmdialog";import{EventEmitterContext}from"../../core/EventEmitterContext";import{MessageType}from"../../hub/CommandMessage";import{useTis}from"./TisProvider";import{SampleSummaryPanel}from"./SampleSummaryPanel";import"./ResultHistoryTable.css";const downloadCsvBlob=(e,t)=>{const o=new Blob([t],{type:"text/csv;charset=utf-8;"}),i=URL.createObjectURL(o),s=document.createElement("a");s.href=i,s.download=e,document.body.appendChild(s),s.click(),s.remove(),URL.revokeObjectURL(i)},downloadViaUrl=(e,t)=>{const o=document.createElement("a");o.href=e,o.download=t,document.body.appendChild(o),o.click(),o.remove()},LIFECYCLE_DONE={complete:{summary:"Test closed",detail:e=>`${e} is now Completed. It can still be reopened.`},abandon:{summary:"Test abandoned",detail:e=>`${e} is out of the history and out of the sample averages. Its data is still on disk — use Show abandoned to see it.`},reopen:{summary:"Test reopened",detail:e=>`${e} is open again — new cycles are added to it.`}};export const ResultHistoryTable=e=>{const t=useTis(),o=e.projectId??t.selection.projectId,i=e.methodId,[s,a]=useState([]),[r,n]=useState(!1),[l,d]=useState(null),[c,p]=useState(null),[u,m]=useState(null),f=useRef(null),[h,_]=useState(null),b=useRef(0),[y,g]=useState(null),[w,x]=useState(!1),v=useRef(null),{invoke:C}=useContext(EventEmitterContext),j=useRef(0),T=async()=>{if(!o)return void a([]);const e=++j.current;n(!0);try{const t={project_id:o};i&&(t.method_id=i);const s=await C("tis.list_tests",MessageType.Request,t);if(e!==j.current)return;s.success&&s.data&&s.data.tests&&a(s.data.tests)}catch(e){}e===j.current&&n(!1)};useEffect(()=>{T()},[o,i,t.state.activeRunId,t.state.active]),useEffect(()=>{h&&f.current?.show({currentTarget:h.anchor})},[h]);const $=async(e,t)=>{const s=e?.run_id,a=e?.method_id??i;if(!s||!a||!o)return;const r="report"===t?"tis.export_test_csv":"tis.export_test_data_csv",n="report"===t?"test report":"test data";d({runId:s,kind:t});try{const e=await C(r,MessageType.Request,{project_id:o,method_id:a,run_id:s});if(!e?.success)return void alert(`Failed to build ${n} for ${s}`+(e?.error_message?`: ${e.error_message}`:""));const i="string"==typeof e.data?.filename&&e.data.filename?e.data.filename:`${o}_${a}_${s}_${t}.csv`;if("report"===t){const t="string"==typeof e.data?.csv?e.data.csv:"";if(!t)return void alert(`${n} for ${s} is empty.`);downloadCsvBlob(i,t)}else{const t="string"==typeof e.data?.download_url?e.data.download_url:"";if(!t)return void alert(`Server did not return a download URL for ${n}.`);downloadViaUrl(t,i)}}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{d(null)}},R=async(e,t)=>{const s=e?.run_id,a=e?.method_id??i;if(!s||!a||!o)return;const r="complete"===t?"tis.finish_test":"abandon"===t?"tis.reset_test":"tis.resume_test",n=e?.sample_id||s;g(s);try{const e=await C(r,MessageType.Request,{project_id:o,method_id:a,run_id:s});if(!e?.success)return void alert(`Could not ${t} ${s}`+(e?.error_message?`:\n\n${e.error_message}`:""));await T(),v.current?.show({severity:"abandon"===t?"warn":"success",summary:LIFECYCLE_DONE[t].summary,detail:LIFECYCLE_DONE[t].detail(n),life:4e3})}catch(e){alert(`${t} failed: ${e instanceof Error?e.message:String(e)}`)}finally{g(null)}},S=async e=>{if(!o)return;const t="report"===e?"tis.export_project_csv":"tis.export_project_zip",i="report"===e?"project report":"project archive";p(e);try{const s=await C(t,MessageType.Request,{project_id:o});if(!s?.success)return void alert(`Failed to build ${i}`+(s?.error_message?`: ${s.error_message}`:""));const a="string"==typeof s.data?.download_url?s.data.download_url:"";if(!a)return void alert(`Server did not return a download URL for ${i}.`);const r="report"===e?`${o}_project_report.csv`:`${o}_project_archive.zip`,n="string"==typeof s.data?.filename&&s.data.filename?s.data.filename:r;downloadViaUrl(a,n)}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{p(null)}},k=e=>{const t="string"==typeof e?.sample_id?e.sample_id:"";if(t)return t;const o=e?.config;return o&&"object"==typeof o&&"string"==typeof o.sample_id?o.sample_id:""},I=s.filter(e=>!0===e?.finalized).length,E=w?s:s.filter(e=>!0!==e?.finalized);return _jsxs("div",{style:{width:"100%",maxWidth:"100%",overflow:"hidden",boxSizing:"border-box"},children:[_jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"1rem",gap:"0.5rem",flexWrap:"wrap"},children:[_jsx("h3",{style:{margin:0},children:o?`Test History: ${o}${i?` / ${i}`:""}`:"Test History (no project selected)"}),_jsxs("div",{style:{display:"flex",gap:"0.4rem",alignItems:"center"},children:[I>0&&_jsx(Button,{icon:w?"pi pi-eye-slash":"pi pi-eye",label:w?"Hide abandoned":`Show abandoned (${I})`,size:"small",outlined:!0,severity:"secondary",onClick:()=>x(e=>!e)}),_jsx(Button,{icon:"report"===c?"pi pi-spin pi-spinner":"pi pi-file",label:"Download Report",size:"small",outlined:!0,disabled:!o||null!==c,onClick:()=>S("report"),tooltip:"Download a CSV report of every test in this project",tooltipOptions:{position:"bottom"}}),_jsx(Button,{icon:"archive"===c?"pi pi-spin pi-spinner":"pi pi-box",label:"Download Archive",size:"small",outlined:!0,disabled:!o||null!==c,onClick:()=>S("archive"),tooltip:"Download a ZIP of the entire project directory (all tests, raw data, configs)",tooltipOptions:{position:"bottom"}}),_jsx(Button,{icon:"pi pi-refresh",label:"Refresh",size:"small",onClick:T,disabled:r})]})]}),_jsx(SampleSummaryPanel,{projectId:o,methodId:i}),_jsxs(DataTable,{value:E,loading:r,paginator:!0,rows:10,emptyMessage:"No tests found.",scrollable:!0,scrollHeight:"flex",tableStyle:{minWidth:0},style:{width:"100%"},rowClassName:e=>!0===e?.excluded?"tis-row-excluded":"",selectionMode:"single",onSelectionChange:e=>{const o=e.value;o?.run_id&&t.setSelection({projectId:o.project_id??null,methodId:o.method_id??null,runId:o.run_id})},children:[_jsx(Column,{header:"Sample ID",sortable:!0,body:k,sortFunction:e=>{const t=[...e.data];return t.sort((t,o)=>k(t).localeCompare(k(o))*(e.order??1)),t},style:{minWidth:"8rem"}}),_jsx(Column,{field:"start_time",header:"Date/Time",sortable:!0,body:e=>{return(t=e.start_time)?new Date(t).toLocaleString():"";var t},style:{minWidth:"12rem"}}),_jsx(Column,{field:"method_id",header:"Test Method",sortable:!0,style:{minWidth:"10rem"}}),_jsx(Column,{header:"Status",field:"status",sortable:!0,body:e=>{const t=(e=>{if(!0===e?.finalized)return{label:"Abandoned",severity:"danger"};const t="string"==typeof e?.status?e.status:"completed";if("completed"===t&&!0===e?.auto_closed)return{label:"Interrupted",severity:"warning"};switch(t){case"in_progress":return{label:"In Progress",severity:"info"};case"paused":return{label:"Paused",severity:"warning"};case"completed":return{label:"Completed",severity:"success"};default:return{label:t,severity:void 0}}})(e);return _jsx(Badge,{value:t.label,severity:t.severity})},style:{minWidth:"6rem"}}),_jsx(Column,{header:"Incl.",align:"center",alignHeader:"center",style:{width:"4.5rem"},body:e=>{const t=!0===e?.excluded,s=u===e.run_id;return _jsx(Button,{icon:s?"pi pi-spin pi-spinner":t?"pi pi-eye-slash":"pi pi-check",rounded:!0,text:!0,size:"small",severity:t?"warning":"success",disabled:null!==u,onClick:()=>(async e=>{const t=e?.run_id,s=e?.method_id??i;if(!t||!s||!o)return;const a=!0!==e?.excluded;m(t);try{const e=await C("tis.set_test_excluded",MessageType.Request,{project_id:o,method_id:s,run_id:t,excluded:a});if(!e?.success)return void alert(`Failed to ${a?"exclude":"include"} ${t}`+(e?.error_message?`: ${e.error_message}`:""));await T()}catch(e){alert(`Exclude toggle failed: ${e instanceof Error?e.message:String(e)}`)}finally{m(null)}})(e),"aria-label":t?"Excluded — click to include":"Included — click to exclude",tooltip:t?"Excluded from cross-test results — click to include":"Included in cross-test results — click to exclude",tooltipOptions:{position:"left"}})}}),_jsx(Column,{header:"Download",style:{width:"14rem"},body:e=>{const t=l?.runId===e.run_id&&"data"===l?.kind,o=l?.runId===e.run_id&&"report"===l?.kind,i=null!==l;return _jsxs("div",{style:{display:"flex",gap:"0.4rem"},children:[_jsx(Button,{icon:t?"pi pi-spin pi-spinner":"pi pi-download",label:"Raw",size:"small",outlined:!0,disabled:i,onClick:()=>$(e,"data"),tooltip:"Download raw + filtered trace data as one CSV",tooltipOptions:{position:"left"}}),_jsx(Button,{icon:o?"pi pi-spin pi-spinner":"pi pi-file",label:"Results",size:"small",outlined:!0,disabled:i,onClick:()=>$(e,"report"),tooltip:"Download a CSV report (metadata + cycles + results) for this test",tooltipOptions:{position:"left"}})]})}}),_jsx(Column,{header:"",style:{width:"3rem"},body:e=>_jsx(Button,{icon:y===e.run_id?"pi pi-spin pi-spinner":"pi pi-ellipsis-v",text:!0,rounded:!0,size:"small",disabled:null!==y,"aria-label":"Test actions",tooltip:"Complete / abandon / reopen this test",tooltipOptions:{position:"left"},onClick:t=>{b.current+=1,_({row:e,anchor:t.currentTarget,seq:b.current})}})})]}),_jsx(Menu,{model:h?(e=>{const t=e?.status,o=!0===e?.finalized,i="in_progress"===t||"paused"===t,s="completed"===t&&!0===e?.auto_closed&&!o,a=[];return i?(a.push({label:"Complete Test",icon:"pi pi-check-circle",command:()=>R(e,"complete")}),a.push({label:"Abandon Test",icon:"pi pi-ban",command:()=>confirmDialog({header:"Abandon Test",message:`Mark ${e?.sample_id||e?.run_id} as abandoned? Recorded data is kept, but the test can no longer be reopened.`,icon:"pi pi-exclamation-triangle",acceptClassName:"p-button-danger",acceptLabel:"Abandon",accept:()=>R(e,"abandon")})})):s?(a.push({label:"Close Test",icon:"pi pi-check-circle",command:()=>confirmDialog({header:"Close Interrupted Test",message:`Close ${e?.sample_id||e?.run_id}? It was closed automatically when the software restarted mid-test. Closing it marks it Completed and keeps the recorded end time. Recorded data is not changed, and the test can still be reopened later.`,icon:"pi pi-check-circle",acceptLabel:"Close Test",accept:()=>R(e,"complete")})}),a.push({label:"Reopen Test",icon:"pi pi-replay",command:()=>confirmDialog({header:"Reopen Test",message:`Reopen ${e?.sample_id||e?.run_id} so new cycles are added to it? Cycle numbering continues from where it stopped. This changes where data is recorded — it does not start the machine.`,icon:"pi pi-replay",acceptLabel:"Reopen",accept:()=>R(e,"reopen")})}),a.push({label:"Abandon Test",icon:"pi pi-ban",command:()=>confirmDialog({header:"Abandon Test",message:`Mark ${e?.sample_id||e?.run_id} as abandoned? Recorded data is kept, but the test can no longer be reopened.`,icon:"pi pi-exclamation-triangle",acceptClassName:"p-button-danger",acceptLabel:"Abandon",accept:()=>R(e,"abandon")})})):"completed"!==t||o?a.push({label:o?"Abandoned — cannot reopen":"No actions available",icon:"pi pi-info-circle",disabled:!0}):a.push({label:"Reopen Test",icon:"pi pi-replay",command:()=>confirmDialog({header:"Reopen Test",message:`Reopen ${e?.sample_id||e?.run_id} so new cycles are added to it? Cycle numbering continues from where it stopped. This changes where data is recorded — it does not start the machine.`,icon:"pi pi-replay",acceptLabel:"Reopen",accept:()=>R(e,"reopen")})}),a})(h.row):[],popup:!0,ref:f}),_jsx(Toast,{ref:v,position:"top-right"})]})};
|
|
1
|
+
import{jsx as _jsx,Fragment as _Fragment,jsxs as _jsxs}from"react/jsx-runtime";import React,{useState,useEffect,useContext,useRef}from"react";import{DataTable}from"primereact/datatable";import{Column}from"primereact/column";import{Button}from"primereact/button";import{Badge}from"primereact/badge";import{Menu}from"primereact/menu";import{Toast}from"primereact/toast";import{Dialog}from"primereact/dialog";import{confirmDialog}from"primereact/confirmdialog";import{EventEmitterContext}from"../../core/EventEmitterContext";import{MessageType}from"../../hub/CommandMessage";import{useTis}from"./TisProvider";import{SampleSummaryPanel}from"./SampleSummaryPanel";import"./ResultHistoryTable.css";const downloadCsvBlob=(e,t)=>{const a=new Blob([t],{type:"text/csv;charset=utf-8;"}),i=URL.createObjectURL(a),s=document.createElement("a");s.href=i,s.download=e,document.body.appendChild(s),s.click(),s.remove(),URL.revokeObjectURL(i)},downloadViaUrl=(e,t)=>{const a=document.createElement("a");a.href=e,a.download=t,document.body.appendChild(a),a.click(),a.remove()},LIFECYCLE_DONE={complete:{summary:"Test closed",detail:e=>`${e} is now Completed. It can still be reopened.`},abandon:{summary:"Test abandoned",detail:e=>`${e} is out of the history and out of the sample averages. Its data is still on disk — use Show abandoned to see it.`},reopen:{summary:"Test reopened",detail:e=>`${e} is open again — new cycles are added to it.`}},stateOf=e=>{const t="string"==typeof e?.status?e.status:"completed",a=!0===e?.finalized,i="completed"===t&&!0===e?.auto_closed,s="in_progress"===t||"paused"===t,o="completed"===t&&!i&&!a;return{lifecycle:a?{label:"Abandoned",severity:"danger"}:"in_progress"===t?{label:"In Progress",severity:"info"}:"paused"===t?{label:"Paused",severity:"warning"}:i?{label:"Interrupted",severity:"warning"}:"completed"===t?{label:"Completed",severity:"success"}:{label:t,severity:void 0},excluded:!0===e?.excluded?{label:"Excluded",severity:"secondary"}:null,live:s,abandoned:a,interrupted:i,settled:o}},stateChipsOf=e=>{const{lifecycle:t,excluded:a}=stateOf(e);return a?[t,a]:[t]},statusOf=e=>{const{lifecycle:t,excluded:a,settled:i}=stateOf(e);return a&&i?a:t},sampleIdOf=e=>{const t="string"==typeof e?.sample_id?e.sample_id:"";if(t)return t;const a=e?.config;return a&&"object"==typeof a&&"string"==typeof a.sample_id?a.sample_id:""},formatDate=e=>e?new Date(e).toLocaleString():"",DetailRow=({label:e,value:t,mono:a})=>null==t||""===t?null:_jsxs(_Fragment,{children:[_jsx("dt",{style:{color:"var(--text-color-secondary)",fontSize:"0.9em"},children:e}),_jsx("dd",{style:{margin:0,fontFamily:a?"monospace":void 0},children:t})]}),DetailGrid=({title:e,children:t})=>_jsxs("section",{style:{marginTop:"1rem"},children:[_jsx("h4",{style:{margin:"0 0 0.4rem",fontSize:"0.95em"},children:e}),_jsx("dl",{style:{display:"grid",gridTemplateColumns:"minmax(9rem, max-content) 1fr",gap:"0.3rem 1rem",margin:0},children:t})]}),configValueText=e=>null==e?"—":"number"==typeof e?Number.isFinite(e)?String(Number.parseFloat(e.toPrecision(6))):String(e):"boolean"==typeof e?e?"Yes":"No":"object"==typeof e?JSON.stringify(e):String(e),TestDetails=({row:e})=>{const t=stateChipsOf(e),{abandoned:a}=stateOf(e),i=e?.config&&"object"==typeof e.config?e.config:{},s=Object.entries(i).filter(([e])=>"sample_id"!==e);return _jsxs("div",{children:[_jsx("div",{style:{display:"flex",gap:"0.4rem",flexWrap:"wrap"},children:t.map(e=>_jsx(Badge,{value:e.label,severity:e.severity},e.label))}),_jsxs(DetailGrid,{title:"Identity",children:[_jsx(DetailRow,{label:"Sample ID",value:sampleIdOf(e)}),_jsx(DetailRow,{label:"Test Method",value:e?.method_id}),_jsx(DetailRow,{label:"Project",value:e?.project_id}),_jsx(DetailRow,{label:"Test ID",value:e?.run_id,mono:!0})]}),_jsxs(DetailGrid,{title:"Timing",children:[_jsx(DetailRow,{label:"Started",value:formatDate(e?.start_time)}),_jsx(DetailRow,{label:"Ended",value:formatDate(e?.end_time)}),_jsx(DetailRow,{label:"Paused",value:formatDate(e?.paused_time)}),_jsx(DetailRow,{label:"Resumed",value:formatDate(e?.resumed_time)})]}),(e?.close_reason||!0===e?.excluded||a)&&_jsxs(DetailGrid,{title:"State",children:[_jsx(DetailRow,{label:"Interrupted",value:e?.close_reason}),_jsx(DetailRow,{label:"Excluded",value:!0===e?.excluded?`Not counted in the ${sampleIdOf(e)||"sample"} results`+(e?.excluded_at?` · since ${formatDate(e.excluded_at)}`:"")+". Use the Incl. column to put it back.":void 0}),_jsx(DetailRow,{label:"Abandoned",value:a?"Kept on disk, out of the results, and cannot be reopened.":void 0})]}),s.length>0&&_jsx(DetailGrid,{title:"Configuration as recorded",children:s.map(([e,t])=>_jsx(DetailRow,{label:e,value:configValueText(t)},e))})]})};export const ResultHistoryTable=e=>{const t=useTis(),a=e.projectId??t.selection.projectId,i=e.methodId,[s,o]=useState([]),[r,l]=useState(!1),[n,d]=useState(null),[c,p]=useState(null),[m,u]=useState(null),f=useRef(null),[_,b]=useState(null),h=useRef(0),[y,g]=useState(null),[x,j]=useState(!1),[v,w]=useState(null),C=useRef(null),{invoke:D}=useContext(EventEmitterContext),R=useRef(0),T=async()=>{if(!a)return void o([]);const e=++R.current;l(!0);try{const t={project_id:a};i&&(t.method_id=i);const s=await D("tis.list_tests",MessageType.Request,t);if(e!==R.current)return;s.success&&s.data&&s.data.tests&&o(s.data.tests)}catch(e){}e===R.current&&l(!1)};useEffect(()=>{T()},[a,i,t.state.activeRunId,t.state.active]),useEffect(()=>{_&&f.current?.show({currentTarget:_.anchor})},[_]);const S=async(e,t)=>{const s=e?.run_id,o=e?.method_id??i;if(!s||!o||!a)return;const r="report"===t?"tis.export_test_csv":"tis.export_test_data_csv",l="report"===t?"test report":"test data";d({runId:s,kind:t});try{const e=await D(r,MessageType.Request,{project_id:a,method_id:o,run_id:s});if(!e?.success)return void alert(`Failed to build ${l} for ${s}`+(e?.error_message?`: ${e.error_message}`:""));const i="string"==typeof e.data?.filename&&e.data.filename?e.data.filename:`${a}_${o}_${s}_${t}.csv`;if("report"===t){const t="string"==typeof e.data?.csv?e.data.csv:"";if(!t)return void alert(`${l} for ${s} is empty.`);downloadCsvBlob(i,t)}else{const t="string"==typeof e.data?.download_url?e.data.download_url:"";if(!t)return void alert(`Server did not return a download URL for ${l}.`);downloadViaUrl(t,i)}}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{d(null)}},$=async(e,t)=>{const s=e?.run_id,o=e?.method_id??i;if(!s||!o||!a)return;const r="complete"===t?"tis.finish_test":"abandon"===t?"tis.reset_test":"tis.resume_test",l=e?.sample_id||s;g(s);try{const e=await D(r,MessageType.Request,{project_id:a,method_id:o,run_id:s});if(!e?.success)return void alert(`Could not ${t} ${s}`+(e?.error_message?`:\n\n${e.error_message}`:""));await T(),C.current?.show({severity:"abandon"===t?"warn":"success",summary:LIFECYCLE_DONE[t].summary,detail:LIFECYCLE_DONE[t].detail(l),life:4e3})}catch(e){alert(`${t} failed: ${e instanceof Error?e.message:String(e)}`)}finally{g(null)}},I=async e=>{if(!a)return;const t="report"===e?"tis.export_project_csv":"tis.export_project_zip",i="report"===e?"project report":"project archive";p(e);try{const s=await D(t,MessageType.Request,{project_id:a});if(!s?.success)return void alert(`Failed to build ${i}`+(s?.error_message?`: ${s.error_message}`:""));const o="string"==typeof s.data?.download_url?s.data.download_url:"";if(!o)return void alert(`Server did not return a download URL for ${i}.`);const r="report"===e?`${a}_project_report.csv`:`${a}_project_archive.zip`,l="string"==typeof s.data?.filename&&s.data.filename?s.data.filename:r;downloadViaUrl(o,l)}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{p(null)}},k=s.filter(e=>!0===e?.finalized).length,E=x?s:s.filter(e=>!0!==e?.finalized);return _jsxs("div",{style:{width:"100%",maxWidth:"100%",overflow:"hidden",boxSizing:"border-box"},children:[_jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"1rem",gap:"0.5rem",flexWrap:"wrap"},children:[_jsx("h3",{style:{margin:0},children:a?`Test History: ${a}${i?` / ${i}`:""}`:"Test History (no project selected)"}),_jsxs("div",{style:{display:"flex",gap:"0.4rem",alignItems:"center"},children:[k>0&&_jsx(Button,{icon:x?"pi pi-eye-slash":"pi pi-eye",label:x?"Hide abandoned":`Show abandoned (${k})`,size:"small",outlined:!0,severity:"secondary",onClick:()=>j(e=>!e)}),_jsx(Button,{icon:"report"===c?"pi pi-spin pi-spinner":"pi pi-file",label:"Download Report",size:"small",outlined:!0,disabled:!a||null!==c,onClick:()=>I("report")}),_jsx(Button,{icon:"archive"===c?"pi pi-spin pi-spinner":"pi pi-box",label:"Download Archive",size:"small",outlined:!0,disabled:!a||null!==c,onClick:()=>I("archive")}),_jsx(Button,{icon:"pi pi-refresh",label:"Refresh",size:"small",onClick:T,disabled:r})]})]}),_jsx(SampleSummaryPanel,{projectId:a,methodId:i}),_jsxs(DataTable,{value:E,loading:r,paginator:!0,rows:10,emptyMessage:"No tests found.",scrollable:!0,scrollHeight:"flex",tableStyle:{minWidth:0},style:{width:"100%"},rowClassName:e=>!0===e?.excluded?"tis-row-excluded":"",selectionMode:"single",onSelectionChange:e=>{const a=e.value;a?.run_id&&t.setSelection({projectId:a.project_id??null,methodId:a.method_id??null,runId:a.run_id})},children:[_jsx(Column,{header:"Sample ID",sortable:!0,body:sampleIdOf,sortFunction:e=>{const t=[...e.data];return t.sort((t,a)=>sampleIdOf(t).localeCompare(sampleIdOf(a))*(e.order??1)),t},style:{minWidth:"8rem"}}),_jsx(Column,{field:"start_time",header:"Date/Time",sortable:!0,body:e=>formatDate(e.start_time),style:{minWidth:"12rem"}}),_jsx(Column,{field:"method_id",header:"Test Method",sortable:!0,style:{minWidth:"10rem"}}),_jsx(Column,{header:"Status",field:"status",sortable:!0,body:e=>{const t=statusOf(e);return _jsx(Badge,{value:t.label,severity:t.severity})},style:{minWidth:"6rem"}}),_jsx(Column,{header:"Incl.",align:"center",alignHeader:"center",style:{width:"4.5rem"},body:e=>{const t=!0===e?.excluded,s=m===e.run_id;return _jsx(Button,{icon:s?"pi pi-spin pi-spinner":t?"pi pi-eye-slash":"pi pi-check",rounded:!0,text:!0,size:"small",severity:t?"warning":"success",disabled:null!==m,onClick:()=>(async e=>{const t=e?.run_id,s=e?.method_id??i;if(!t||!s||!a)return;const o=!0!==e?.excluded;u(t);try{const e=await D("tis.set_test_excluded",MessageType.Request,{project_id:a,method_id:s,run_id:t,excluded:o});if(!e?.success)return void alert(`Failed to ${o?"exclude":"include"} ${t}`+(e?.error_message?`: ${e.error_message}`:""));await T()}catch(e){alert(`Exclude toggle failed: ${e instanceof Error?e.message:String(e)}`)}finally{u(null)}})(e),"aria-label":t?"Excluded — click to include":"Included — click to exclude"})}}),_jsx(Column,{header:"Download",style:{width:"14rem"},body:e=>{const t=n?.runId===e.run_id&&"data"===n?.kind,a=n?.runId===e.run_id&&"report"===n?.kind,i=null!==n;return _jsxs("div",{style:{display:"flex",gap:"0.4rem"},children:[_jsx(Button,{icon:t?"pi pi-spin pi-spinner":"pi pi-download",label:"Raw",size:"small",outlined:!0,disabled:i,onClick:()=>S(e,"data")}),_jsx(Button,{icon:a?"pi pi-spin pi-spinner":"pi pi-file",label:"Results",size:"small",outlined:!0,disabled:i,onClick:()=>S(e,"report")})]})}}),_jsx(Column,{header:"",style:{width:"3rem"},body:e=>_jsx(Button,{icon:y===e.run_id?"pi pi-spin pi-spinner":"pi pi-ellipsis-v",text:!0,rounded:!0,size:"small",disabled:null!==y,"aria-label":"Test actions",onClick:t=>{h.current+=1,b({row:e,anchor:t.currentTarget,seq:h.current})}})})]}),_jsx(Menu,{model:_?(e=>{const t=e?.status,a=!0===e?.finalized,i="in_progress"===t||"paused"===t,s="completed"===t&&!0===e?.auto_closed&&!a,o=[{label:"Details",icon:"pi pi-info-circle",command:()=>w(e)},{separator:!0}];return i?(o.push({label:"Complete Test",icon:"pi pi-check-circle",command:()=>$(e,"complete")}),o.push({label:"Abandon Test",icon:"pi pi-ban",command:()=>confirmDialog({header:"Abandon Test",message:`Mark ${e?.sample_id||e?.run_id} as abandoned? Recorded data is kept, but the test can no longer be reopened.`,icon:"pi pi-exclamation-triangle",acceptClassName:"p-button-danger",acceptLabel:"Abandon",accept:()=>$(e,"abandon")})})):s?(o.push({label:"Close Test",icon:"pi pi-check-circle",command:()=>confirmDialog({header:"Close Interrupted Test",message:`Close ${e?.sample_id||e?.run_id}? It was closed automatically when the software restarted mid-test. Closing it marks it Completed and keeps the recorded end time. Recorded data is not changed, and the test can still be reopened later.`,icon:"pi pi-check-circle",acceptLabel:"Close Test",accept:()=>$(e,"complete")})}),o.push({label:"Reopen Test",icon:"pi pi-replay",command:()=>confirmDialog({header:"Reopen Test",message:`Reopen ${e?.sample_id||e?.run_id} so new cycles are added to it? Cycle numbering continues from where it stopped. This changes where data is recorded — it does not start the machine.`,icon:"pi pi-replay",acceptLabel:"Reopen",accept:()=>$(e,"reopen")})}),o.push({label:"Abandon Test",icon:"pi pi-ban",command:()=>confirmDialog({header:"Abandon Test",message:`Mark ${e?.sample_id||e?.run_id} as abandoned? Recorded data is kept, but the test can no longer be reopened.`,icon:"pi pi-exclamation-triangle",acceptClassName:"p-button-danger",acceptLabel:"Abandon",accept:()=>$(e,"abandon")})})):"completed"!==t||a?a&&o.push({label:"Abandoned — cannot reopen",icon:"pi pi-ban",disabled:!0}):o.push({label:"Reopen Test",icon:"pi pi-replay",command:()=>confirmDialog({header:"Reopen Test",message:`Reopen ${e?.sample_id||e?.run_id} so new cycles are added to it? Cycle numbering continues from where it stopped. This changes where data is recorded — it does not start the machine.`,icon:"pi pi-replay",acceptLabel:"Reopen",accept:()=>$(e,"reopen")})}),o})(_.row):[],popup:!0,ref:f}),_jsx(Toast,{ref:C,position:"top-right"}),_jsx(Dialog,{header:"Test Details",visible:null!==v,onHide:()=>w(null),style:{width:"min(44rem, 94vw)"},dismissableMask:!0,children:v&&_jsx(TestDetails,{row:v})})]})};
|
package/package.json
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
/* Runs excluded from cross-test aggregation are kept visible but dimmed
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/* Runs excluded from cross-test aggregation are kept visible but dimmed, so an
|
|
2
|
+
operator can see at a glance which rows don't feed the avg/min/max/stddev/
|
|
3
|
+
count results for their sample group. The row's Status column says the word
|
|
4
|
+
"Excluded" outright, and its Details dialog says what that means — the dim
|
|
5
|
+
is a scanning aid, never the only signal.
|
|
6
|
+
|
|
7
|
+
NO line-through. It used to be here and it read as "deleted": an excluded
|
|
8
|
+
run is intact, still downloadable, and one click from being counted again.
|
|
9
|
+
|
|
10
|
+
Nor is there an exception rule for the action buttons. There was one —
|
|
11
|
+
`.tis-row-excluded > td .p-button { opacity: 1; text-decoration: none }` —
|
|
12
|
+
and neither half of it could ever have worked, because both properties come
|
|
13
|
+
from the ancestor `td`:
|
|
14
|
+
|
|
15
|
+
• `opacity` on the td composites its whole subtree as one group. A
|
|
16
|
+
descendant's `opacity: 1` cannot escape an ancestor's 0.55.
|
|
17
|
+
• `text-decoration` propagates from an ancestor to its in-flow
|
|
18
|
+
descendants and cannot be cancelled by a descendant at all.
|
|
19
|
+
|
|
20
|
+
So anything that must be exempt has to be exempt at the td: put the effect
|
|
21
|
+
on the text cells, not on every cell. Kept as one rule here because 0.55
|
|
22
|
+
leaves the buttons legible and they are what the operator reaches for. */
|
|
4
23
|
.tis-row-excluded > td {
|
|
5
24
|
opacity: 0.55;
|
|
6
|
-
text-decoration: line-through;
|
|
7
|
-
}
|
|
8
|
-
/* Keep the action buttons (Include / Download) legible and clickable. */
|
|
9
|
-
.tis-row-excluded > td .p-button {
|
|
10
|
-
opacity: 1;
|
|
11
|
-
text-decoration: none;
|
|
12
25
|
}
|
|
@@ -5,6 +5,7 @@ import { Button } from 'primereact/button';
|
|
|
5
5
|
import { Badge } from 'primereact/badge';
|
|
6
6
|
import { Menu } from 'primereact/menu';
|
|
7
7
|
import { Toast } from 'primereact/toast';
|
|
8
|
+
import { Dialog } from 'primereact/dialog';
|
|
8
9
|
import { confirmDialog } from 'primereact/confirmdialog';
|
|
9
10
|
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
10
11
|
import { MessageType } from '../../hub/CommandMessage';
|
|
@@ -89,10 +90,217 @@ const LIFECYCLE_DONE: Record<
|
|
|
89
90
|
},
|
|
90
91
|
};
|
|
91
92
|
|
|
93
|
+
type ChipSeverity = 'success' | 'info' | 'warning' | 'danger' | 'secondary' | undefined;
|
|
94
|
+
export interface StateChip { label: string; severity: ChipSeverity }
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A run's state on its two independent axes.
|
|
98
|
+
*
|
|
99
|
+
* `lifecycle` is what happened to the run — written by the TIS servelet
|
|
100
|
+
* (start_test → in_progress, pause_test → paused, finish_test → completed,
|
|
101
|
+
* reset_test → finalized, and `auto_closed` by the startup sweep). Runs
|
|
102
|
+
* recorded before those fields existed carry neither; treat them as completed,
|
|
103
|
+
* since they are long since done.
|
|
104
|
+
*
|
|
105
|
+
* `excluded` is a separate judgement about the DATA, set only by the Incl.
|
|
106
|
+
* toggle, and it does not replace the lifecycle — a run can be Completed and
|
|
107
|
+
* Excluded at once. Which of the two the narrow Status column shows is
|
|
108
|
+
* [`statusOf`]'s decision; the Details dialog shows both.
|
|
109
|
+
*/
|
|
110
|
+
const stateOf = (rowData: any) => {
|
|
111
|
+
const status = typeof rowData?.status === 'string' ? rowData.status : 'completed';
|
|
112
|
+
const abandoned = rowData?.finalized === true;
|
|
113
|
+
const interrupted = status === 'completed' && rowData?.auto_closed === true;
|
|
114
|
+
// Open right now, so nothing may hide it.
|
|
115
|
+
const live = status === 'in_progress' || status === 'paused';
|
|
116
|
+
// Finished, and nobody is waiting on the operator for anything. The only
|
|
117
|
+
// lifecycle state that has nothing left to say.
|
|
118
|
+
const settled = status === 'completed' && !interrupted && !abandoned;
|
|
119
|
+
|
|
120
|
+
const lifecycle: StateChip =
|
|
121
|
+
abandoned ? { label: 'Abandoned', severity: 'danger' }
|
|
122
|
+
: status === 'in_progress' ? { label: 'In Progress', severity: 'info' }
|
|
123
|
+
: status === 'paused' ? { label: 'Paused', severity: 'warning' }
|
|
124
|
+
// Closed by the startup sweep because the previous process stopped
|
|
125
|
+
// mid-test. It IS closed, but the operator never finished it, so
|
|
126
|
+
// "Completed" would overstate what happened. Still reopenable —
|
|
127
|
+
// `finalized` is deliberately not set by that sweep.
|
|
128
|
+
: interrupted ? { label: 'Interrupted', severity: 'warning' }
|
|
129
|
+
: status === 'completed' ? { label: 'Completed', severity: 'success' }
|
|
130
|
+
: { label: status, severity: undefined };
|
|
131
|
+
|
|
132
|
+
const excluded: StateChip | null = rowData?.excluded === true
|
|
133
|
+
? { label: 'Excluded', severity: 'secondary' }
|
|
134
|
+
: null;
|
|
135
|
+
|
|
136
|
+
return { lifecycle, excluded, live, abandoned, interrupted, settled };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Every chip that applies to a run, in reading order. Used by the dialog. */
|
|
140
|
+
const stateChipsOf = (rowData: any): StateChip[] => {
|
|
141
|
+
const { lifecycle, excluded } = stateOf(rowData);
|
|
142
|
+
return excluded ? [lifecycle, excluded] : [lifecycle];
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The one chip the Status column has room for.
|
|
147
|
+
*
|
|
148
|
+
* Excluded takes the column from a **settled** run: it is out of the results,
|
|
149
|
+
* nothing is owed to it, and "Completed" is no longer the thing the operator
|
|
150
|
+
* needs to read off the row — so the narrow column spends its one chip on the
|
|
151
|
+
* state that still means something.
|
|
152
|
+
*
|
|
153
|
+
* A lifecycle state that is WAITING ON SOMEONE keeps the column instead, even
|
|
154
|
+
* when the run is excluded: In Progress and Paused because nothing may mask
|
|
155
|
+
* what the machine is doing right now, Interrupted because it is a request to
|
|
156
|
+
* close the run out and hiding it is how a run stays interrupted forever, and
|
|
157
|
+
* Abandoned because it is the reason the row is hidden at all and
|
|
158
|
+
* "Show abandoned" has to explain itself.
|
|
159
|
+
*
|
|
160
|
+
* Whatever loses the column is still on the row's Details dialog, which is
|
|
161
|
+
* where both chips always appear together.
|
|
162
|
+
*/
|
|
163
|
+
const statusOf = (rowData: any): StateChip => {
|
|
164
|
+
const { lifecycle, excluded, settled } = stateOf(rowData);
|
|
165
|
+
return excluded && settled ? excluded : lifecycle;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A run's effective sample_id: top-level wins, falling back to the deprecated
|
|
170
|
+
* nested `config.sample_id` so runs recorded by an older server still show a
|
|
171
|
+
* label. Mirrors the server's `sample_id_of`.
|
|
172
|
+
*/
|
|
173
|
+
const sampleIdOf = (rowData: any): string => {
|
|
174
|
+
const top = typeof rowData?.sample_id === 'string' ? rowData.sample_id : '';
|
|
175
|
+
if (top) return top;
|
|
176
|
+
const cfg = rowData?.config;
|
|
177
|
+
if (cfg && typeof cfg === 'object' && typeof cfg.sample_id === 'string') {
|
|
178
|
+
return cfg.sample_id;
|
|
179
|
+
}
|
|
180
|
+
return '';
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Local date/time, or an empty string for an absent stamp. */
|
|
184
|
+
const formatDate = (dateStr: string) => {
|
|
185
|
+
if (!dateStr) return '';
|
|
186
|
+
return new Date(dateStr).toLocaleString();
|
|
187
|
+
};
|
|
188
|
+
|
|
92
189
|
type DownloadKind = 'data' | 'report';
|
|
93
190
|
type InFlight = { runId: string; kind: DownloadKind };
|
|
94
191
|
type ProjectDownloadKind = 'report' | 'archive';
|
|
95
192
|
|
|
193
|
+
/** One label/value line in the details dialog. Absent values are omitted. */
|
|
194
|
+
const DetailRow: React.FC<{ label: string; value?: React.ReactNode; mono?: boolean }> =
|
|
195
|
+
({ label, value, mono }) => {
|
|
196
|
+
if (value === undefined || value === null || value === '') return null;
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
<dt style={{ color: 'var(--text-color-secondary)', fontSize: '0.9em' }}>{label}</dt>
|
|
200
|
+
<dd style={{ margin: 0, fontFamily: mono ? 'monospace' : undefined }}>{value}</dd>
|
|
201
|
+
</>
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** Label/value block. `dl` grid so the values line up down the column. */
|
|
206
|
+
const DetailGrid: React.FC<{ title: string; children: React.ReactNode }> =
|
|
207
|
+
({ title, children }) => (
|
|
208
|
+
<section style={{ marginTop: '1rem' }}>
|
|
209
|
+
<h4 style={{ margin: '0 0 0.4rem', fontSize: '0.95em' }}>{title}</h4>
|
|
210
|
+
<dl style={{
|
|
211
|
+
display: 'grid',
|
|
212
|
+
gridTemplateColumns: 'minmax(9rem, max-content) 1fr',
|
|
213
|
+
gap: '0.3rem 1rem',
|
|
214
|
+
margin: 0,
|
|
215
|
+
}}>
|
|
216
|
+
{children}
|
|
217
|
+
</dl>
|
|
218
|
+
</section>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render one recorded configuration value. These are stored in the method's
|
|
223
|
+
* backend units — the dialog reports what is on the record rather than
|
|
224
|
+
* converting, because the record is what it exists to show. Long floats are
|
|
225
|
+
* trimmed to six significant figures so a column of them stays readable.
|
|
226
|
+
*/
|
|
227
|
+
const configValueText = (v: unknown): string => {
|
|
228
|
+
if (v === null || v === undefined) return '—';
|
|
229
|
+
if (typeof v === 'number') {
|
|
230
|
+
return Number.isFinite(v) ? String(Number.parseFloat(v.toPrecision(6))) : String(v);
|
|
231
|
+
}
|
|
232
|
+
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
|
|
233
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
234
|
+
return String(v);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The body of the Details dialog: state, identity, timing, and the recorded
|
|
239
|
+
* configuration. Split out so the state chips and the table's Status column
|
|
240
|
+
* are demonstrably reading the same `stateOf`.
|
|
241
|
+
*/
|
|
242
|
+
const TestDetails: React.FC<{ row: any }> = ({ row }) => {
|
|
243
|
+
const chips = stateChipsOf(row);
|
|
244
|
+
const { abandoned } = stateOf(row);
|
|
245
|
+
const config = (row?.config && typeof row.config === 'object') ? row.config : {};
|
|
246
|
+
// sample_id lives at the top level now; an older record kept it in config,
|
|
247
|
+
// where it is identity rather than a setting.
|
|
248
|
+
const configEntries = Object.entries(config).filter(([k]) => k !== 'sample_id');
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div>
|
|
252
|
+
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
|
253
|
+
{chips.map((c) => <Badge key={c.label} value={c.label} severity={c.severity} />)}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<DetailGrid title="Identity">
|
|
257
|
+
<DetailRow label="Sample ID" value={sampleIdOf(row)} />
|
|
258
|
+
<DetailRow label="Test Method" value={row?.method_id} />
|
|
259
|
+
<DetailRow label="Project" value={row?.project_id} />
|
|
260
|
+
<DetailRow label="Test ID" value={row?.run_id} mono />
|
|
261
|
+
</DetailGrid>
|
|
262
|
+
|
|
263
|
+
<DetailGrid title="Timing">
|
|
264
|
+
<DetailRow label="Started" value={formatDate(row?.start_time)} />
|
|
265
|
+
<DetailRow label="Ended" value={formatDate(row?.end_time)} />
|
|
266
|
+
<DetailRow label="Paused" value={formatDate(row?.paused_time)} />
|
|
267
|
+
<DetailRow label="Resumed" value={formatDate(row?.resumed_time)} />
|
|
268
|
+
</DetailGrid>
|
|
269
|
+
|
|
270
|
+
{/* Only rendered when one of these actually applies, so an ordinary
|
|
271
|
+
completed run shows no section at all rather than a row of
|
|
272
|
+
dashes explaining nothing. */}
|
|
273
|
+
{(row?.close_reason || row?.excluded === true || abandoned) && (
|
|
274
|
+
<DetailGrid title="State">
|
|
275
|
+
<DetailRow label="Interrupted" value={row?.close_reason} />
|
|
276
|
+
<DetailRow
|
|
277
|
+
label="Excluded"
|
|
278
|
+
value={row?.excluded === true
|
|
279
|
+
? `Not counted in the ${sampleIdOf(row) || 'sample'} results` +
|
|
280
|
+
(row?.excluded_at ? ` · since ${formatDate(row.excluded_at)}` : '') +
|
|
281
|
+
'. Use the Incl. column to put it back.'
|
|
282
|
+
: undefined}
|
|
283
|
+
/>
|
|
284
|
+
<DetailRow
|
|
285
|
+
label="Abandoned"
|
|
286
|
+
value={abandoned
|
|
287
|
+
? 'Kept on disk, out of the results, and cannot be reopened.'
|
|
288
|
+
: undefined}
|
|
289
|
+
/>
|
|
290
|
+
</DetailGrid>
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{configEntries.length > 0 && (
|
|
294
|
+
<DetailGrid title="Configuration as recorded">
|
|
295
|
+
{configEntries.map(([k, v]) => (
|
|
296
|
+
<DetailRow key={k} label={k} value={configValueText(v)} />
|
|
297
|
+
))}
|
|
298
|
+
</DetailGrid>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
};
|
|
303
|
+
|
|
96
304
|
export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) => {
|
|
97
305
|
const tis = useTis();
|
|
98
306
|
const projectId = props.projectId ?? tis.selection.projectId;
|
|
@@ -121,6 +329,10 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
121
329
|
// Abandoned runs are hidden by default — see `visibleTests`. The toggle
|
|
122
330
|
// is how they stay auditable without cluttering the operator's list.
|
|
123
331
|
const [showAbandoned, setShowAbandoned] = useState(false);
|
|
332
|
+
// The row whose Details dialog is open, captured from the menu. A snapshot
|
|
333
|
+
// rather than a run_id lookup: the dialog describes the row the operator
|
|
334
|
+
// opened it from, and closes long before the next refetch matters.
|
|
335
|
+
const [detailsRow, setDetailsRow] = useState<any>(null);
|
|
124
336
|
const toastRef = useRef<Toast>(null);
|
|
125
337
|
const { invoke } = useContext(EventEmitterContext);
|
|
126
338
|
|
|
@@ -179,11 +391,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
179
391
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
180
392
|
}, [menuRequest]);
|
|
181
393
|
|
|
182
|
-
const formatDate = (dateStr: string) => {
|
|
183
|
-
if (!dateStr) return '';
|
|
184
|
-
return new Date(dateStr).toLocaleString();
|
|
185
|
-
};
|
|
186
|
-
|
|
187
394
|
const handleDownload = async (rowData: any, kind: DownloadKind) => {
|
|
188
395
|
const runId = rowData?.run_id;
|
|
189
396
|
const rowMethodId = rowData?.method_id ?? methodId;
|
|
@@ -314,6 +521,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
314
521
|
* - finalized (abandoned) → nothing; `finalized` exists precisely to
|
|
315
522
|
* stop a deliberately-abandoned run being resurrected. Only reachable
|
|
316
523
|
* at all with `Show abandoned` on.
|
|
524
|
+
*
|
|
525
|
+
* Details is offered on EVERY row, first, and is the one item that is
|
|
526
|
+
* never gated: it is where the full state, the run's own identity and the
|
|
527
|
+
* recorded configuration are legible, and it is what the narrow Status
|
|
528
|
+
* column defers to.
|
|
317
529
|
*/
|
|
318
530
|
const rowMenuItems = (rowData: any) => {
|
|
319
531
|
const status = rowData?.status;
|
|
@@ -324,7 +536,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
324
536
|
// still reads as Interrupted until someone closes it.
|
|
325
537
|
const isInterrupted =
|
|
326
538
|
status === 'completed' && rowData?.auto_closed === true && !finalized;
|
|
327
|
-
const items: any[] = [
|
|
539
|
+
const items: any[] = [{
|
|
540
|
+
label: 'Details',
|
|
541
|
+
icon: 'pi pi-info-circle',
|
|
542
|
+
command: () => setDetailsRow(rowData),
|
|
543
|
+
}, { separator: true }];
|
|
328
544
|
|
|
329
545
|
if (isOpen) {
|
|
330
546
|
items.push({
|
|
@@ -405,10 +621,13 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
405
621
|
accept: () => handleLifecycle(rowData, 'reopen'),
|
|
406
622
|
}),
|
|
407
623
|
});
|
|
408
|
-
} else {
|
|
624
|
+
} else if (finalized) {
|
|
625
|
+
// Nothing to offer, but say why rather than leaving the operator to
|
|
626
|
+
// infer it from an absence. A run with no lifecycle action at all
|
|
627
|
+
// still has Details above.
|
|
409
628
|
items.push({
|
|
410
|
-
label:
|
|
411
|
-
icon: 'pi pi-
|
|
629
|
+
label: 'Abandoned — cannot reopen',
|
|
630
|
+
icon: 'pi pi-ban',
|
|
412
631
|
disabled: true,
|
|
413
632
|
});
|
|
414
633
|
}
|
|
@@ -482,20 +701,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
482
701
|
}
|
|
483
702
|
};
|
|
484
703
|
|
|
485
|
-
// sample_id is now a top-level field on test.json. Older test.json
|
|
486
|
-
// files (written before the rename) carry it nested in `config`; fall
|
|
487
|
-
// back to the nested form so we can still display the column for runs
|
|
488
|
-
// recorded by an older server.
|
|
489
|
-
const sampleIdOf = (rowData: any) => {
|
|
490
|
-
const top = typeof rowData?.sample_id === 'string' ? rowData.sample_id : '';
|
|
491
|
-
if (top) return top;
|
|
492
|
-
const cfg = rowData?.config;
|
|
493
|
-
if (cfg && typeof cfg === 'object' && typeof cfg.sample_id === 'string') {
|
|
494
|
-
return cfg.sample_id;
|
|
495
|
-
}
|
|
496
|
-
return '';
|
|
497
|
-
};
|
|
498
|
-
|
|
499
704
|
/**
|
|
500
705
|
* Abandoning a run is the operator saying "this one never happened":
|
|
501
706
|
* TIS drops it from the sample aggregates, and it drops out of this list
|
|
@@ -509,27 +714,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
509
714
|
? tests
|
|
510
715
|
: tests.filter((t) => t?.finalized !== true);
|
|
511
716
|
|
|
512
|
-
// Lifecycle status badge. `status`/`finalized` are written by the TIS
|
|
513
|
-
// servelet (start_test → in_progress, pause_test → paused, finish_test →
|
|
514
|
-
// completed, reset_test → finalized). Runs recorded before this field
|
|
515
|
-
// existed have neither; treat them as completed (they're long since done).
|
|
516
|
-
const statusOf = (rowData: any): { label: string; severity: 'success' | 'info' | 'warning' | 'danger' | undefined } => {
|
|
517
|
-
if (rowData?.finalized === true) return { label: 'Abandoned', severity: 'danger' };
|
|
518
|
-
const status = typeof rowData?.status === 'string' ? rowData.status : 'completed';
|
|
519
|
-
// A run TIS closed at startup because the previous process stopped
|
|
520
|
-
// mid-test. It is closed (not "In Progress"), but the operator never
|
|
521
|
-
// finished it, so calling it "Completed" would overstate what happened.
|
|
522
|
-
// Still reopenable — `finalized` is deliberately not set by that sweep.
|
|
523
|
-
if (status === 'completed' && rowData?.auto_closed === true) {
|
|
524
|
-
return { label: 'Interrupted', severity: 'warning' };
|
|
525
|
-
}
|
|
526
|
-
switch (status) {
|
|
527
|
-
case 'in_progress': return { label: 'In Progress', severity: 'info' };
|
|
528
|
-
case 'paused': return { label: 'Paused', severity: 'warning' };
|
|
529
|
-
case 'completed': return { label: 'Completed', severity: 'success' };
|
|
530
|
-
default: return { label: status, severity: undefined };
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
717
|
|
|
534
718
|
return (
|
|
535
719
|
// Outer wrapper pins the whole component to its container's width.
|
|
@@ -568,8 +752,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
568
752
|
outlined
|
|
569
753
|
disabled={!projectId || projectBusy !== null}
|
|
570
754
|
onClick={() => handleProjectDownload('report')}
|
|
571
|
-
tooltip="Download a CSV report of every test in this project"
|
|
572
|
-
tooltipOptions={{ position: 'bottom' }}
|
|
573
755
|
/>
|
|
574
756
|
<Button
|
|
575
757
|
icon={projectBusy === 'archive' ? 'pi pi-spin pi-spinner' : 'pi pi-box'}
|
|
@@ -578,8 +760,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
578
760
|
outlined
|
|
579
761
|
disabled={!projectId || projectBusy !== null}
|
|
580
762
|
onClick={() => handleProjectDownload('archive')}
|
|
581
|
-
tooltip="Download a ZIP of the entire project directory (all tests, raw data, configs)"
|
|
582
|
-
tooltipOptions={{ position: 'bottom' }}
|
|
583
763
|
/>
|
|
584
764
|
<Button
|
|
585
765
|
icon="pi pi-refresh"
|
|
@@ -675,10 +855,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
675
855
|
onClick={() => handleToggleExclude(rowData)}
|
|
676
856
|
aria-label={isExcluded ? 'Excluded — click to include'
|
|
677
857
|
: 'Included — click to exclude'}
|
|
678
|
-
tooltip={isExcluded
|
|
679
|
-
? 'Excluded from cross-test results — click to include'
|
|
680
|
-
: 'Included in cross-test results — click to exclude'}
|
|
681
|
-
tooltipOptions={{ position: 'left' }}
|
|
682
858
|
/>
|
|
683
859
|
);
|
|
684
860
|
}}
|
|
@@ -699,8 +875,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
699
875
|
outlined
|
|
700
876
|
disabled={anyBusy}
|
|
701
877
|
onClick={() => handleDownload(rowData, 'data')}
|
|
702
|
-
tooltip="Download raw + filtered trace data as one CSV"
|
|
703
|
-
tooltipOptions={{ position: 'left' }}
|
|
704
878
|
/>
|
|
705
879
|
<Button
|
|
706
880
|
icon={isReportBusy ? 'pi pi-spin pi-spinner' : 'pi pi-file'}
|
|
@@ -709,8 +883,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
709
883
|
outlined
|
|
710
884
|
disabled={anyBusy}
|
|
711
885
|
onClick={() => handleDownload(rowData, 'report')}
|
|
712
|
-
tooltip="Download a CSV report (metadata + cycles + results) for this test"
|
|
713
|
-
tooltipOptions={{ position: 'left' }}
|
|
714
886
|
/>
|
|
715
887
|
</div>
|
|
716
888
|
);
|
|
@@ -733,8 +905,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
733
905
|
size="small"
|
|
734
906
|
disabled={lifecycleBusy !== null}
|
|
735
907
|
aria-label="Test actions"
|
|
736
|
-
tooltip="Complete / abandon / reopen this test"
|
|
737
|
-
tooltipOptions={{ position: 'left' }}
|
|
738
908
|
onClick={(e) => {
|
|
739
909
|
// Capture the DOM anchor now: React clears
|
|
740
910
|
// currentTarget once the handler returns, and the
|
|
@@ -762,6 +932,22 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
762
932
|
control bar, and a message that covers Start/Stop for four
|
|
763
933
|
seconds is worse than no message at all. */}
|
|
764
934
|
<Toast ref={toastRef} position="top-right" />
|
|
935
|
+
|
|
936
|
+
{/* Per-run detail. This is where everything the table has no room
|
|
937
|
+
for is legible: the full state (a Completed run that is also
|
|
938
|
+
Excluded shows both chips), the run's own identity, and the
|
|
939
|
+
configuration it was recorded under. It replaces the tooltips
|
|
940
|
+
this component used to explain itself with — a hover hint is
|
|
941
|
+
unreachable on the touchscreen these machines ship with. */}
|
|
942
|
+
<Dialog
|
|
943
|
+
header="Test Details"
|
|
944
|
+
visible={detailsRow !== null}
|
|
945
|
+
onHide={() => setDetailsRow(null)}
|
|
946
|
+
style={{ width: 'min(44rem, 94vw)' }}
|
|
947
|
+
dismissableMask
|
|
948
|
+
>
|
|
949
|
+
{detailsRow && <TestDetails row={detailsRow} />}
|
|
950
|
+
</Dialog>
|
|
765
951
|
</div>
|
|
766
952
|
);
|
|
767
953
|
};
|