@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.
@@ -1,12 +1,25 @@
1
- /* Runs excluded from cross-test aggregation are kept visible but dimmed and
2
- struck through, so an operator can see at a glance which rows don't feed
3
- the avg/min/max/stddev/count results for their sample group. */
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;AAWvE,OAAO,0BAA0B,CAAC;AAElC,MAAM,WAAW,uBAAuB;IACpC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAyCD,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,EAAE,CAAC,uBAAuB,CA8lBhE,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,6 +1,6 @@
1
1
  {
2
2
  "name": "@adcops/autocore-react",
3
- "version": "3.6.6",
3
+ "version": "3.6.8",
4
4
  "description": "A React component library for industrial user interfaces.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -1,12 +1,25 @@
1
- /* Runs excluded from cross-test aggregation are kept visible but dimmed and
2
- struck through, so an operator can see at a glance which rows don't feed
3
- the avg/min/max/stddev/count results for their sample group. */
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: finalized ? 'Abandoned — cannot reopen' : 'No actions available',
357
- icon: 'pi pi-info-circle',
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
- // sample_id is now a top-level field on test.json. Older test.json
432
- // files (written before the rename) carry it nested in `config`; fall
433
- // back to the nested form so we can still display the column for runs
434
- // recorded by an older server.
435
- const sampleIdOf = (rowData: any) => {
436
- const top = typeof rowData?.sample_id === 'string' ? rowData.sample_id : '';
437
- if (top) return top;
438
- const cfg = rowData?.config;
439
- if (cfg && typeof cfg === 'object' && typeof cfg.sample_id === 'string') {
440
- return cfg.sample_id;
441
- }
442
- return '';
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={tests}
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
  };