@adcops/autocore-react 3.6.6 → 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 +337 -60
|
@@ -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{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()};export const ResultHistoryTable=e=>{const t=useTis(),o=e.projectId??t.selection.projectId,i=e.methodId,[s,r]=useState([]),[a,n]=useState(!1),[l,d]=useState(null),[c,p]=useState(null),[u,m]=useState(null),f=useRef(null),[_,h]=useState(null),b=useRef(0),[g,y]=useState(null),{invoke:x}=useContext(EventEmitterContext),w=async()=>{if(o){n(!0);try{const e={project_id:o};i&&(e.method_id=i);const t=await x("tis.list_tests",MessageType.Request,e);t.success&&t.data&&t.data.tests&&r(t.data.tests)}catch(e){}n(!1)}else r([])};useEffect(()=>{w()},[o,i,t.state.activeRunId,t.state.active]),useEffect(()=>{_&&f.current?.show({currentTarget:_.anchor})},[_]);const v=async(e,t)=>{const s=e?.run_id,r=e?.method_id??i;if(!s||!r||!o)return;const a="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 x(a,MessageType.Request,{project_id:o,method_id:r,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}_${r}_${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)}},j=async(e,t)=>{const s=e?.run_id,r=e?.method_id??i;if(!s||!r||!o)return;const a="complete"===t?"tis.finish_test":"abandon"===t?"tis.reset_test":"tis.resume_test";y(s);try{const e=await x(a,MessageType.Request,{project_id:o,method_id:r,run_id:s});if(!e?.success)return void alert(`Could not ${t} ${s}`+(e?.error_message?`:\n\n${e.error_message}`:""));await w()}catch(e){alert(`${t} failed: ${e instanceof Error?e.message:String(e)}`)}finally{y(null)}},C=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 x(t,MessageType.Request,{project_id:o});if(!s?.success)return void alert(`Failed to build ${i}`+(s?.error_message?`: ${s.error_message}`:""));const r="string"==typeof s.data?.download_url?s.data.download_url:"";if(!r)return void alert(`Server did not return a download URL for ${i}.`);const a="report"===e?`${o}_project_report.csv`:`${o}_project_archive.zip`,n="string"==typeof s.data?.filename&&s.data.filename?s.data.filename:a;downloadViaUrl(r,n)}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{p(null)}},T=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:""};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:[_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:()=>C("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:()=>C("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:w,disabled:a})]})]}),_jsx(SampleSummaryPanel,{projectId:o,methodId:i}),_jsxs(DataTable,{value:s,loading:a,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:T,sortFunction:e=>{const t=[...e.data];return t.sort((t,o)=>T(t).localeCompare(T(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 r=!0!==e?.excluded;m(t);try{const e=await x("tis.set_test_excluded",MessageType.Request,{project_id:o,method_id:s,run_id:t,excluded:r});if(!e?.success)return void alert(`Failed to ${r?"exclude":"include"} ${t}`+(e?.error_message?`: ${e.error_message}`:""));await w()}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:()=>v(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:()=>v(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:g===e.run_id?"pi pi-spin pi-spinner":"pi pi-ellipsis-v",text:!0,rounded:!0,size:"small",disabled:null!==g,"aria-label":"Test actions",tooltip:"Complete / abandon / reopen this test",tooltipOptions:{position:"left"},onClick:t=>{b.current+=1,h({row:e,anchor:t.currentTarget,seq:b.current})}})})]}),_jsx(Menu,{model:_?(e=>{const t=e?.status,o=!0===e?.finalized,i="in_progress"===t||"paused"===t,s="completed"===t&&!0===e?.auto_closed&&!o,r=[];return i?(r.push({label:"Complete Test",icon:"pi pi-check-circle",command:()=>j(e,"complete")}),r.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:()=>j(e,"abandon")})})):s?(r.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:()=>j(e,"complete")})}),r.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:()=>j(e,"reopen")})}),r.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:()=>j(e,"abandon")})})):"completed"!==t||o?r.push({label:o?"Abandoned — cannot reopen":"No actions available",icon:"pi pi-info-circle",disabled:!0}):r.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:()=>j(e,"reopen")})}),r})(_.row):[],popup:!0,ref:f})]})};
|
|
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
|
}
|
|
@@ -4,6 +4,8 @@ import { Column } from 'primereact/column';
|
|
|
4
4
|
import { Button } from 'primereact/button';
|
|
5
5
|
import { Badge } from 'primereact/badge';
|
|
6
6
|
import { Menu } from 'primereact/menu';
|
|
7
|
+
import { Toast } from 'primereact/toast';
|
|
8
|
+
import { Dialog } from 'primereact/dialog';
|
|
7
9
|
import { confirmDialog } from 'primereact/confirmdialog';
|
|
8
10
|
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
9
11
|
import { MessageType } from '../../hub/CommandMessage';
|
|
@@ -63,10 +65,242 @@ const downloadViaUrl = (url: string, filename: string) => {
|
|
|
63
65
|
a.remove();
|
|
64
66
|
};
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* What each lifecycle action tells the operator once the server has agreed.
|
|
70
|
+
* A table rather than a switch so the wording for an action lives in exactly
|
|
71
|
+
* one place, next to the other two it has to read consistently with.
|
|
72
|
+
*/
|
|
73
|
+
const LIFECYCLE_DONE: Record<
|
|
74
|
+
'complete' | 'abandon' | 'reopen',
|
|
75
|
+
{ summary: string; detail: (label: string) => string }
|
|
76
|
+
> = {
|
|
77
|
+
complete: {
|
|
78
|
+
summary: 'Test closed',
|
|
79
|
+
detail: (l) => `${l} is now Completed. It can still be reopened.`,
|
|
80
|
+
},
|
|
81
|
+
abandon: {
|
|
82
|
+
summary: 'Test abandoned',
|
|
83
|
+
detail: (l) =>
|
|
84
|
+
`${l} is out of the history and out of the sample averages. ` +
|
|
85
|
+
`Its data is still on disk — use Show abandoned to see it.`,
|
|
86
|
+
},
|
|
87
|
+
reopen: {
|
|
88
|
+
summary: 'Test reopened',
|
|
89
|
+
detail: (l) => `${l} is open again — new cycles are added to it.`,
|
|
90
|
+
},
|
|
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
|
+
|
|
66
189
|
type DownloadKind = 'data' | 'report';
|
|
67
190
|
type InFlight = { runId: string; kind: DownloadKind };
|
|
68
191
|
type ProjectDownloadKind = 'report' | 'archive';
|
|
69
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
|
+
|
|
70
304
|
export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) => {
|
|
71
305
|
const tis = useTis();
|
|
72
306
|
const projectId = props.projectId ?? tis.selection.projectId;
|
|
@@ -92,10 +326,27 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
92
326
|
useState<{ row: any; anchor: HTMLElement; seq: number } | null>(null);
|
|
93
327
|
const menuSeqRef = useRef(0);
|
|
94
328
|
const [lifecycleBusy, setLifecycleBusy] = useState<string | null>(null);
|
|
329
|
+
// Abandoned runs are hidden by default — see `visibleTests`. The toggle
|
|
330
|
+
// is how they stay auditable without cluttering the operator's list.
|
|
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);
|
|
336
|
+
const toastRef = useRef<Toast>(null);
|
|
95
337
|
const { invoke } = useContext(EventEmitterContext);
|
|
96
338
|
|
|
339
|
+
// Guards `setTests` against a stale response. `loadTests` is called both
|
|
340
|
+
// directly (Refresh, and after a lifecycle action) and from an effect that
|
|
341
|
+
// re-fires on every active-run change, so two fetches are routinely in
|
|
342
|
+
// flight at once. Without this the OLDER response can land last and put
|
|
343
|
+
// the row the operator just closed back to "Interrupted" — which reads,
|
|
344
|
+
// correctly, as "the button did nothing".
|
|
345
|
+
const loadSeqRef = useRef(0);
|
|
346
|
+
|
|
97
347
|
const loadTests = async () => {
|
|
98
348
|
if (!projectId) { setTests([]); return; }
|
|
349
|
+
const seq = ++loadSeqRef.current;
|
|
99
350
|
setLoading(true);
|
|
100
351
|
try {
|
|
101
352
|
// When the parent omits methodId, ask the server for all runs
|
|
@@ -105,13 +356,16 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
105
356
|
if (methodId) payload.method_id = methodId;
|
|
106
357
|
|
|
107
358
|
const resp: any = await invoke('tis.list_tests' as any, MessageType.Request, payload as any);
|
|
359
|
+
// A newer fetch has already been issued (or has already landed):
|
|
360
|
+
// this answer is history, and applying it would undo the newer one.
|
|
361
|
+
if (seq !== loadSeqRef.current) return;
|
|
108
362
|
if (resp.success && resp.data && resp.data.tests) {
|
|
109
363
|
setTests(resp.data.tests);
|
|
110
364
|
}
|
|
111
365
|
} catch (err) {
|
|
112
366
|
console.error("Failed to load tests", err);
|
|
113
367
|
}
|
|
114
|
-
setLoading(false);
|
|
368
|
+
if (seq === loadSeqRef.current) setLoading(false);
|
|
115
369
|
};
|
|
116
370
|
|
|
117
371
|
useEffect(() => {
|
|
@@ -137,11 +391,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
137
391
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
138
392
|
}, [menuRequest]);
|
|
139
393
|
|
|
140
|
-
const formatDate = (dateStr: string) => {
|
|
141
|
-
if (!dateStr) return '';
|
|
142
|
-
return new Date(dateStr).toLocaleString();
|
|
143
|
-
};
|
|
144
|
-
|
|
145
394
|
const handleDownload = async (rowData: any, kind: DownloadKind) => {
|
|
146
395
|
const runId = rowData?.run_id;
|
|
147
396
|
const rowMethodId = rowData?.method_id ?? methodId;
|
|
@@ -227,6 +476,7 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
227
476
|
action === 'complete' ? 'tis.finish_test'
|
|
228
477
|
: action === 'abandon' ? 'tis.reset_test'
|
|
229
478
|
: 'tis.resume_test';
|
|
479
|
+
const label = rowData?.sample_id || runId;
|
|
230
480
|
setLifecycleBusy(runId);
|
|
231
481
|
try {
|
|
232
482
|
const resp: any = await invoke(topic as any, MessageType.Request, {
|
|
@@ -241,6 +491,16 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
241
491
|
return;
|
|
242
492
|
}
|
|
243
493
|
await loadTests();
|
|
494
|
+
// Say what happened. Close and Abandon both leave the run on disk
|
|
495
|
+
// and change only a badge — one of them then hides the row — so
|
|
496
|
+
// without an acknowledgement the operator has no way to tell a
|
|
497
|
+
// successful action from a dead button, and reports the second.
|
|
498
|
+
toastRef.current?.show({
|
|
499
|
+
severity: action === 'abandon' ? 'warn' : 'success',
|
|
500
|
+
summary: LIFECYCLE_DONE[action].summary,
|
|
501
|
+
detail: LIFECYCLE_DONE[action].detail(label),
|
|
502
|
+
life: 4000,
|
|
503
|
+
});
|
|
244
504
|
} catch (err) {
|
|
245
505
|
console.error(`Failed to ${action} test`, err);
|
|
246
506
|
alert(`${action} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -259,7 +519,13 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
259
519
|
* - completed but not finalized → Reopen (add more cycles under the same
|
|
260
520
|
* Test ID — the multi-specimen case where a set turned out to be bigger)
|
|
261
521
|
* - finalized (abandoned) → nothing; `finalized` exists precisely to
|
|
262
|
-
* stop a deliberately-abandoned run being resurrected.
|
|
522
|
+
* stop a deliberately-abandoned run being resurrected. Only reachable
|
|
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.
|
|
263
529
|
*/
|
|
264
530
|
const rowMenuItems = (rowData: any) => {
|
|
265
531
|
const status = rowData?.status;
|
|
@@ -270,7 +536,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
270
536
|
// still reads as Interrupted until someone closes it.
|
|
271
537
|
const isInterrupted =
|
|
272
538
|
status === 'completed' && rowData?.auto_closed === true && !finalized;
|
|
273
|
-
const items: any[] = [
|
|
539
|
+
const items: any[] = [{
|
|
540
|
+
label: 'Details',
|
|
541
|
+
icon: 'pi pi-info-circle',
|
|
542
|
+
command: () => setDetailsRow(rowData),
|
|
543
|
+
}, { separator: true }];
|
|
274
544
|
|
|
275
545
|
if (isOpen) {
|
|
276
546
|
items.push({
|
|
@@ -351,10 +621,13 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
351
621
|
accept: () => handleLifecycle(rowData, 'reopen'),
|
|
352
622
|
}),
|
|
353
623
|
});
|
|
354
|
-
} 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.
|
|
355
628
|
items.push({
|
|
356
|
-
label:
|
|
357
|
-
icon: 'pi pi-
|
|
629
|
+
label: 'Abandoned — cannot reopen',
|
|
630
|
+
icon: 'pi pi-ban',
|
|
358
631
|
disabled: true,
|
|
359
632
|
});
|
|
360
633
|
}
|
|
@@ -428,41 +701,19 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
428
701
|
}
|
|
429
702
|
};
|
|
430
703
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
};
|
|
704
|
+
/**
|
|
705
|
+
* Abandoning a run is the operator saying "this one never happened":
|
|
706
|
+
* TIS drops it from the sample aggregates, and it drops out of this list
|
|
707
|
+
* too. Nothing is deleted — `Show abandoned` brings the rows back with an
|
|
708
|
+
* "Abandoned" badge — but the default view is the set of runs that still
|
|
709
|
+
* count for something, which is what an operator scanning the history is
|
|
710
|
+
* actually looking for.
|
|
711
|
+
*/
|
|
712
|
+
const abandonedCount = tests.filter((t) => t?.finalized === true).length;
|
|
713
|
+
const visibleTests = showAbandoned
|
|
714
|
+
? tests
|
|
715
|
+
: tests.filter((t) => t?.finalized !== true);
|
|
444
716
|
|
|
445
|
-
// Lifecycle status badge. `status`/`finalized` are written by the TIS
|
|
446
|
-
// servelet (start_test → in_progress, pause_test → paused, finish_test →
|
|
447
|
-
// completed, reset_test → finalized). Runs recorded before this field
|
|
448
|
-
// existed have neither; treat them as completed (they're long since done).
|
|
449
|
-
const statusOf = (rowData: any): { label: string; severity: 'success' | 'info' | 'warning' | 'danger' | undefined } => {
|
|
450
|
-
if (rowData?.finalized === true) return { label: 'Abandoned', severity: 'danger' };
|
|
451
|
-
const status = typeof rowData?.status === 'string' ? rowData.status : 'completed';
|
|
452
|
-
// A run TIS closed at startup because the previous process stopped
|
|
453
|
-
// mid-test. It is closed (not "In Progress"), but the operator never
|
|
454
|
-
// finished it, so calling it "Completed" would overstate what happened.
|
|
455
|
-
// Still reopenable — `finalized` is deliberately not set by that sweep.
|
|
456
|
-
if (status === 'completed' && rowData?.auto_closed === true) {
|
|
457
|
-
return { label: 'Interrupted', severity: 'warning' };
|
|
458
|
-
}
|
|
459
|
-
switch (status) {
|
|
460
|
-
case 'in_progress': return { label: 'In Progress', severity: 'info' };
|
|
461
|
-
case 'paused': return { label: 'Paused', severity: 'warning' };
|
|
462
|
-
case 'completed': return { label: 'Completed', severity: 'success' };
|
|
463
|
-
default: return { label: status, severity: undefined };
|
|
464
|
-
}
|
|
465
|
-
};
|
|
466
717
|
|
|
467
718
|
return (
|
|
468
719
|
// Outer wrapper pins the whole component to its container's width.
|
|
@@ -477,6 +728,23 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
477
728
|
: 'Test History (no project selected)'}
|
|
478
729
|
</h3>
|
|
479
730
|
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
|
|
731
|
+
{/* Only offered when there is something to reveal, so the
|
|
732
|
+
ordinary machine never shows a control for a state it
|
|
733
|
+
has never been in. */}
|
|
734
|
+
{abandonedCount > 0 && (
|
|
735
|
+
<Button
|
|
736
|
+
icon={showAbandoned ? 'pi pi-eye-slash' : 'pi pi-eye'}
|
|
737
|
+
label={
|
|
738
|
+
showAbandoned
|
|
739
|
+
? 'Hide abandoned'
|
|
740
|
+
: `Show abandoned (${abandonedCount})`
|
|
741
|
+
}
|
|
742
|
+
size="small"
|
|
743
|
+
outlined
|
|
744
|
+
severity="secondary"
|
|
745
|
+
onClick={() => setShowAbandoned((v) => !v)}
|
|
746
|
+
/>
|
|
747
|
+
)}
|
|
480
748
|
<Button
|
|
481
749
|
icon={projectBusy === 'report' ? 'pi pi-spin pi-spinner' : 'pi pi-file'}
|
|
482
750
|
label="Download Report"
|
|
@@ -484,8 +752,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
484
752
|
outlined
|
|
485
753
|
disabled={!projectId || projectBusy !== null}
|
|
486
754
|
onClick={() => handleProjectDownload('report')}
|
|
487
|
-
tooltip="Download a CSV report of every test in this project"
|
|
488
|
-
tooltipOptions={{ position: 'bottom' }}
|
|
489
755
|
/>
|
|
490
756
|
<Button
|
|
491
757
|
icon={projectBusy === 'archive' ? 'pi pi-spin pi-spinner' : 'pi pi-box'}
|
|
@@ -494,8 +760,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
494
760
|
outlined
|
|
495
761
|
disabled={!projectId || projectBusy !== null}
|
|
496
762
|
onClick={() => handleProjectDownload('archive')}
|
|
497
|
-
tooltip="Download a ZIP of the entire project directory (all tests, raw data, configs)"
|
|
498
|
-
tooltipOptions={{ position: 'bottom' }}
|
|
499
763
|
/>
|
|
500
764
|
<Button
|
|
501
765
|
icon="pi pi-refresh"
|
|
@@ -513,7 +777,7 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
513
777
|
<SampleSummaryPanel projectId={projectId} methodId={methodId} />
|
|
514
778
|
|
|
515
779
|
<DataTable
|
|
516
|
-
value={
|
|
780
|
+
value={visibleTests}
|
|
517
781
|
loading={loading}
|
|
518
782
|
paginator
|
|
519
783
|
rows={10}
|
|
@@ -591,10 +855,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
591
855
|
onClick={() => handleToggleExclude(rowData)}
|
|
592
856
|
aria-label={isExcluded ? 'Excluded — click to include'
|
|
593
857
|
: 'Included — click to exclude'}
|
|
594
|
-
tooltip={isExcluded
|
|
595
|
-
? 'Excluded from cross-test results — click to include'
|
|
596
|
-
: 'Included in cross-test results — click to exclude'}
|
|
597
|
-
tooltipOptions={{ position: 'left' }}
|
|
598
858
|
/>
|
|
599
859
|
);
|
|
600
860
|
}}
|
|
@@ -615,8 +875,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
615
875
|
outlined
|
|
616
876
|
disabled={anyBusy}
|
|
617
877
|
onClick={() => handleDownload(rowData, 'data')}
|
|
618
|
-
tooltip="Download raw + filtered trace data as one CSV"
|
|
619
|
-
tooltipOptions={{ position: 'left' }}
|
|
620
878
|
/>
|
|
621
879
|
<Button
|
|
622
880
|
icon={isReportBusy ? 'pi pi-spin pi-spinner' : 'pi pi-file'}
|
|
@@ -625,8 +883,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
625
883
|
outlined
|
|
626
884
|
disabled={anyBusy}
|
|
627
885
|
onClick={() => handleDownload(rowData, 'report')}
|
|
628
|
-
tooltip="Download a CSV report (metadata + cycles + results) for this test"
|
|
629
|
-
tooltipOptions={{ position: 'left' }}
|
|
630
886
|
/>
|
|
631
887
|
</div>
|
|
632
888
|
);
|
|
@@ -649,8 +905,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
649
905
|
size="small"
|
|
650
906
|
disabled={lifecycleBusy !== null}
|
|
651
907
|
aria-label="Test actions"
|
|
652
|
-
tooltip="Complete / abandon / reopen this test"
|
|
653
|
-
tooltipOptions={{ position: 'left' }}
|
|
654
908
|
onClick={(e) => {
|
|
655
909
|
// Capture the DOM anchor now: React clears
|
|
656
910
|
// currentTarget once the handler returns, and the
|
|
@@ -671,6 +925,29 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
671
925
|
popup
|
|
672
926
|
ref={rowMenuRef}
|
|
673
927
|
/>
|
|
928
|
+
{/* Owned by this component rather than the host app: the library
|
|
929
|
+
cannot assume a consumer has a global message host, and the
|
|
930
|
+
acknowledgement has to appear wherever the table is used.
|
|
931
|
+
Top-right, not bottom: on a machine HMI the bottom strip is the
|
|
932
|
+
control bar, and a message that covers Start/Stop for four
|
|
933
|
+
seconds is worse than no message at all. */}
|
|
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>
|
|
674
951
|
</div>
|
|
675
952
|
);
|
|
676
953
|
};
|