# HG changeset patch
# User ogiorgis <olivier.giorgis@logilab.fr>
# Date 1714127407 -7200
#      Fri Apr 26 12:30:07 2024 +0200
# Node ID c020b7ee26b07b9367fa8ac7d1c7c3752db1b091
# Parent  2f0f08b8747e339bf7992ccce84bfdae04bcde8e
fix: no need to reload logs

diff --git a/frontend/src/components/LogModal.tsx b/frontend/src/components/LogModal.tsx
--- a/frontend/src/components/LogModal.tsx
+++ b/frontend/src/components/LogModal.tsx
@@ -46,10 +46,6 @@
   useEffect(() => {
     if (open) {
       getLogs();
-      const interval = setInterval(getLogs, 5000);
-      return () => {
-        clearInterval(interval);
-      };
     }
   }, [url]);
 
# HG changeset patch
# User Elodie Thieblin <ethieblin@logilab.fr>
# Date 1714125954 -7200
#      Fri Apr 26 12:05:54 2024 +0200
# Node ID 8a9ec412e4597c52393e2be875df705c4308fa13
# Parent  c020b7ee26b07b9367fa8ac7d1c7c3752db1b091
feat: display SHACL Logs in interface

diff --git a/frontend/src/components/ImportProcessTable.tsx b/frontend/src/components/ImportProcessTable.tsx
--- a/frontend/src/components/ImportProcessTable.tsx
+++ b/frontend/src/components/ImportProcessTable.tsx
@@ -28,6 +28,7 @@
 import { ButtonTooltip } from "./ButtonTooltip";
 import { useState, useEffect } from "react";
 import { getFileNameFromURL } from "@/utils";
+import { SHACLLogModal } from "./SHACLLogModal";
 
 export interface ImportProcessTableProps {
   dataServiceEid?: number;
@@ -60,6 +61,19 @@
       console.error("invalid url");
     }
   };
+  const [shaclLogModal, setSHACLLogModal] = useState<{
+    eid: number;
+    url: string;
+    visible: boolean;
+  }>({ eid: 0, url: "", visible: false });
+
+  const setSHACLLogModalVisibility = (eid: number, url?: string) => {
+    if (url) {
+      setSHACLLogModal({ eid, url, visible: true });
+    } else {
+      console.error("invalid url");
+    }
+  };
 
   useEffect(() => {
     const interval = setInterval(refresh, REFRESH_INTERVAL);
@@ -108,6 +122,7 @@
                 importProcess={row}
                 showProjectColumn={showProjectColumn}
                 setLogModalVisibility={setLogModalVisibility}
+                setSHACLLogModalVisibility={setSHACLLogModalVisibility}
               />
             ))}
           </TableBody>
@@ -119,6 +134,12 @@
         url={logModal.url}
         onClose={() => setLogModal({ eid: 0, url: "", visible: false })}
       />
+      <SHACLLogModal
+        open={shaclLogModal.visible}
+        eid={shaclLogModal.eid}
+        url={shaclLogModal.url}
+        onClose={() => setSHACLLogModal({ eid: 0, url: "", visible: false })}
+      />
     </>
   );
 }
@@ -127,10 +148,12 @@
   importProcess,
   showProjectColumn,
   setLogModalVisibility,
+  setSHACLLogModalVisibility,
 }: {
   importProcess: ImportProcess;
   showProjectColumn: boolean;
   setLogModalVisibility: (eid: number, url?: string) => void;
+  setSHACLLogModalVisibility: (eid: number, url?: string) => void;
 }) {
   const hasLog = importProcess.log_url != null;
   const hasReport = importProcess.shacl_report_url != null;
@@ -197,7 +220,7 @@
               color="primary"
               disabled={!hasReport}
               onClick={() =>
-                setLogModalVisibility(
+                setSHACLLogModalVisibility(
                   importProcess.eid,
                   getFileDataFromURL(importProcess.shacl_report_url),
                 )
diff --git a/frontend/src/components/LogModal.tsx b/frontend/src/components/LogModal.tsx
--- a/frontend/src/components/LogModal.tsx
+++ b/frontend/src/components/LogModal.tsx
@@ -14,7 +14,7 @@
 import { useState, useEffect } from "react";
 import { getFileNameFromURL } from "@/utils";
 
-interface LogModalProps {
+export interface LogModalProps {
   open: boolean;
   eid: number;
   url: string;
diff --git a/frontend/src/components/SHACLLogModal.tsx b/frontend/src/components/SHACLLogModal.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/src/components/SHACLLogModal.tsx
@@ -0,0 +1,204 @@
+import {
+  AppBar,
+  Button,
+  Card,
+  Chip,
+  CircularProgress,
+  Container,
+  Dialog,
+  DialogContent,
+  DialogContentText,
+  Divider,
+  IconButton,
+  List,
+  ListItem,
+  TableContainer,
+  Table,
+  TableHead,
+  TableRow,
+  TableCell,
+  TableBody,
+  Toolbar,
+  Typography,
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+
+import { useState, useEffect } from "react";
+import { getFileNameFromURL } from "@/utils";
+import { LogModalProps } from "@/components/LogModal";
+
+interface SHACLLogProps {
+  severity: string;
+  count: number;
+  message: string;
+  constraint: string;
+  property: string;
+  shape: string | null;
+  cases: {
+    nodes: string[];
+    values: string[];
+  };
+}
+
+async function readSHACLLogs(url: string) {
+  const response = await fetch(url, {
+    method: "GET",
+    credentials: "include",
+  });
+  if (response.ok) {
+    const result = await response.json();
+    return result;
+  } else {
+    console.error(response);
+    return null;
+  }
+}
+
+function SHACLLogs({ report }: { report: SHACLLogProps[] | null }) {
+  if (report === null || report === undefined) {
+    return "Could not fetch logs";
+  }
+  return (
+    <>
+      {report.map((prop, index) => (
+        <Container key={`shacl_${index}`}>
+          <SHACLLogDisplay {...prop}></SHACLLogDisplay>
+        </Container>
+      ))}
+    </>
+  );
+}
+
+export function SHACLLogDisplay({
+  count,
+  severity,
+  shape,
+  message,
+  constraint,
+  property,
+  cases,
+}: SHACLLogProps) {
+  const [exampleSize, setExampleSize] = useState(5);
+  const shape_component =
+    shape !== null ? (
+      <ListItem>
+        <Chip label="Shape"></Chip> {shape}
+      </ListItem>
+    ) : (
+      <></>
+    );
+
+  return (
+    <Card style={{ marginTop: "2em" }}>
+      <h3>
+        <Chip label={`${severity}`} color="error"></Chip>
+        {`  ${count} : ${message}`}{" "}
+      </h3>
+      <Divider></Divider>
+      <List>
+        {shape_component}
+        <ListItem>
+          <Chip label="Constraint"></Chip> {constraint}
+        </ListItem>
+        <ListItem>
+          <Chip label="Property"></Chip> {property}
+        </ListItem>
+      </List>
+      <Divider></Divider>
+
+      <TableContainer>
+        <Table>
+          <TableHead>
+            <TableRow>
+              <TableCell>Focus Node</TableCell>
+              <TableCell>Values</TableCell>
+            </TableRow>
+          </TableHead>
+          <TableBody>
+            {cases.nodes.slice(0, exampleSize).map((node, index) => (
+              <TableRow
+                key={`${node}_${constraint}`}
+                sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
+              >
+                <TableCell component="th" scope="row">
+                  {node}
+                </TableCell>
+                <TableCell>{cases.values[index]}</TableCell>
+              </TableRow>
+            ))}
+          </TableBody>
+        </Table>
+      </TableContainer>
+      {cases.nodes.length > exampleSize ? (
+        <Button
+          onClick={() => {
+            setExampleSize(exampleSize + 5);
+          }}
+        >
+          Voir plus
+        </Button>
+      ) : (
+        <></>
+      )}
+    </Card>
+  );
+}
+
+export function SHACLLogModal({ open, eid, url, onClose }: LogModalProps) {
+  const [shaclReport, setSHACLReport] = useState(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    if (open) {
+      getSHACLLogs();
+    }
+  }, [url]);
+
+  async function getSHACLLogs() {
+    setSHACLReport(await readSHACLLogs(url));
+    setLoading(false);
+  }
+
+  const onModalClose = () => {
+    setLoading(true);
+    onClose();
+  };
+
+  return (
+    <Dialog
+      open={open}
+      onClose={onClose}
+      aria-labelledby="dialog-title"
+      aria-describedby="dialog-description"
+      fullScreen
+    >
+      <AppBar sx={{ position: "relative" }}>
+        <Toolbar>
+          <IconButton
+            edge="start"
+            color="inherit"
+            onClick={onModalClose}
+            aria-label="Fermer"
+          >
+            <CloseIcon />
+          </IconButton>
+          <Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
+            SHACL Report for Import Process {eid}
+          </Typography>
+          <Button color="primary" href={url} download={getFileNameFromURL(url)}>
+            Télécharger en JSON
+          </Button>
+        </Toolbar>
+      </AppBar>
+      <DialogContent>
+        <DialogContentText component={"span"} id="dialog-description">
+          {loading ? (
+            <CircularProgress />
+          ) : (
+            <SHACLLogs report={shaclReport}></SHACLLogs>
+          )}
+        </DialogContentText>
+      </DialogContent>
+    </Dialog>
+  );
+}
# HG changeset patch
# User Simon Chabot <simon.chabot@logilab.fr>
# Date 1714121571 -7200
#      Fri Apr 26 10:52:51 2024 +0200
# Node ID 2ed13a9cdc48338307d0c9c7d9a6142a0ba6e668
# Parent  8a9ec412e4597c52393e2be875df705c4308fa13
feat(front): show a message error when the creation of the project or dataservice fails

related: #92

diff --git a/frontend/src/components/DataServiceForm.tsx b/frontend/src/components/DataServiceForm.tsx
--- a/frontend/src/components/DataServiceForm.tsx
+++ b/frontend/src/components/DataServiceForm.tsx
@@ -35,6 +35,7 @@
 import FileUploadIcon from "@mui/icons-material/FileUpload";
 import CancelIcon from "@mui/icons-material/Cancel";
 import { useUploadDataServiceFile } from "@/hooks/useUploadDataServiceFile";
+import { ErrorScreen } from "./ErrorScreen";
 
 export interface DataServiceFormProps {
   dataService?: DataService;
@@ -42,6 +43,7 @@
 
 export function DataServiceForm({ dataService }: DataServiceFormProps) {
   const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | undefined>(undefined);
   const { handleSubmit, formState, reset, ...useFormProps } =
     useForm<DataService>({
       defaultValues: dataService
@@ -70,7 +72,12 @@
         setLoading(false);
       }
     } catch (e) {
-      handleAuthErrors(e);
+      try {
+        handleAuthErrors(e);
+      } catch (e) {
+        setLoading(false);
+        setError((e as Error).message);
+      }
     }
   };
 
@@ -201,6 +208,7 @@
           </Box>
         </Stack>
       </form>
+      {error && <ErrorScreen message={error} />}
     </FormProvider>
   );
 }
diff --git a/frontend/src/components/ProjectForm.tsx b/frontend/src/components/ProjectForm.tsx
--- a/frontend/src/components/ProjectForm.tsx
+++ b/frontend/src/components/ProjectForm.tsx
@@ -7,6 +7,7 @@
 import { LoadingButton } from "@mui/lab";
 import { useHandleAuthErrors } from "@/hooks/useHandleAuthErrors";
 import { useApiCreateProject, useApiUpdateProject } from "@/api/cubicweb";
+import { ErrorScreen } from "./ErrorScreen";
 import { FileField } from "./FileField";
 
 export interface ProjectFormProps {
@@ -15,6 +16,7 @@
 
 export function ProjectForm({ project }: ProjectFormProps) {
   const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | undefined>(undefined);
   const {
     handleSubmit,
     control,
@@ -42,21 +44,63 @@
         setLoading(false);
       }
     } catch (e) {
-      handleAuthErrors(e);
+      try {
+        handleAuthErrors(e);
+      } catch (e) {
+        setLoading(false);
+        setError((e as Error).message);
+      }
     }
   };
 
   return (
-    <form onSubmit={handleSubmit(onSubmit)}>
-      <Stack spacing={2}>
-        {!project ? (
+    <>
+      <form onSubmit={handleSubmit(onSubmit)}>
+        <Stack spacing={2}>
+          {!project ? (
+            <Controller
+              name="name"
+              control={control}
+              rules={{ required: true }}
+              render={({ field, fieldState: { error } }) => (
+                <TextField
+                  label="Nom"
+                  required={true}
+                  disabled={loading}
+                  {...field}
+                  error={error !== undefined}
+                  helperText={
+                    error?.type === "required" ? "Champ requis" : error?.message
+                  }
+                />
+              )}
+            />
+          ) : null}
           <Controller
-            name="name"
+            name="activated"
+            control={control}
+            render={({ field }) => (
+              <Box width={150} display={"flex"} alignItems={"center"}>
+                <FormControlLabel
+                  control={
+                    <Switch
+                      {...field}
+                      checked={field.value}
+                      disabled={loading}
+                    />
+                  }
+                  label={field.value ? "Actif" : "Inactif"}
+                />
+              </Box>
+            )}
+          />
+          <Controller
+            name="virtuoso_url"
             control={control}
             rules={{ required: true }}
             render={({ field, fieldState: { error } }) => (
               <TextField
-                label="Nom"
+                label="Virtuoso URL"
                 required={true}
                 disabled={loading}
                 {...field}
@@ -64,105 +108,75 @@
                 helperText={
                   error?.type === "required" ? "Champ requis" : error?.message
                 }
+                fullWidth
+              />
+            )}
+          />
+          <Controller
+            name="virtuoso_user"
+            control={control}
+            rules={{ required: true }}
+            render={({ field, fieldState: { error } }) => (
+              <TextField
+                label="Virtuoso User"
+                required={true}
+                disabled={loading}
+                {...field}
+                error={error !== undefined}
+                helperText={
+                  error?.type === "required" ? "Champ requis" : error?.message
+                }
+                fullWidth
               />
             )}
           />
-        ) : null}
-        <Controller
-          name="activated"
-          control={control}
-          render={({ field }) => (
-            <Box width={150} display={"flex"} alignItems={"center"}>
-              <FormControlLabel
-                control={
-                  <Switch {...field} checked={field.value} disabled={loading} />
+          <Controller
+            name="virtuoso_password"
+            control={control}
+            rules={{ required: true }}
+            render={({ field, fieldState: { error } }) => (
+              <TextField
+                label="Virtuoso Password"
+                type="password"
+                required={true}
+                disabled={loading}
+                {...field}
+                error={error !== undefined}
+                helperText={
+                  error?.type === "required" ? "Champ requis" : error?.message
                 }
-                label={field.value ? "Actif" : "Inactif"}
+                fullWidth
               />
-            </Box>
-          )}
-        />
-        <Controller
-          name="virtuoso_url"
-          control={control}
-          rules={{ required: true }}
-          render={({ field, fieldState: { error } }) => (
-            <TextField
-              label="Virtuoso URL"
-              required={true}
-              disabled={loading}
-              {...field}
-              error={error !== undefined}
-              helperText={
-                error?.type === "required" ? "Champ requis" : error?.message
-              }
-              fullWidth
-            />
-          )}
-        />
-        <Controller
-          name="virtuoso_user"
-          control={control}
-          rules={{ required: true }}
-          render={({ field, fieldState: { error } }) => (
-            <TextField
-              label="Virtuoso User"
-              required={true}
-              disabled={loading}
-              {...field}
-              error={error !== undefined}
-              helperText={
-                error?.type === "required" ? "Champ requis" : error?.message
-              }
-              fullWidth
-            />
-          )}
-        />
-        <Controller
-          name="virtuoso_password"
-          control={control}
-          rules={{ required: true }}
-          render={({ field, fieldState: { error } }) => (
-            <TextField
-              label="Virtuoso Password"
-              type="password"
-              required={true}
-              disabled={loading}
-              {...field}
-              error={error !== undefined}
-              helperText={
-                error?.type === "required" ? "Champ requis" : error?.message
-              }
-              fullWidth
-            />
-          )}
-        />
-        <Controller
-          name="ontology_file"
-          control={control}
-          render={({ field }) => (
-            <FileField label="Ontologie" {...field} disabled={loading} />
-          )}
-        />
-        <Controller
-          name="shacl_files"
-          control={control}
-          render={({ field }) => (
-            <FileField label="SHACL" {...field} disabled={loading} />
-          )}
-        />
-        <Box display={"flex"} flexDirection={"row-reverse"}>
-          <LoadingButton
-            variant="contained"
-            startIcon={<SaveIcon />}
-            type="submit"
-            loading={loading}
-            disabled={!isDirty}
-          >
-            {project ? "Sauvegarder" : "Ajouter"}
-          </LoadingButton>
-        </Box>
-      </Stack>
-    </form>
+            )}
+          />
+          <Controller
+            name="ontology_file"
+            control={control}
+            render={({ field }) => (
+              <FileField label="Ontologie" {...field} disabled={loading} />
+            )}
+          />
+          <Controller
+            name="shacl_files"
+            control={control}
+            render={({ field }) => (
+              <FileField label="SHACL" {...field} disabled={loading} />
+            )}
+          />
+          <Box display={"flex"} flexDirection={"row-reverse"}>
+            <LoadingButton
+              variant="contained"
+              startIcon={<SaveIcon />}
+              type="submit"
+              loading={loading}
+              disabled={!isDirty}
+            >
+              {project ? "Sauvegarder" : "Ajouter"}
+            </LoadingButton>
+          </Box>
+        </Stack>
+      </form>
+      {error && <ErrorScreen message={error} />}
+    </>
   );
 }
# HG changeset patch
# User Simon Chabot <simon.chabot@logilab.fr>
# Date 1714121619 -7200
#      Fri Apr 26 10:53:39 2024 +0200
# Node ID c1fb94f63665c700d414270d44dec6bfd614e86d
# Parent  2ed13a9cdc48338307d0c9c7d9a6142a0ba6e668
fix(front): correctly handle default value of content_type for dataService

before that patch, the defaultValue was set in the Select components, but not in
the state of the sent object. Therefore we had a validation error on creation.
To bypass this error, the user had to select an other content_type (and
re-select turtle… if the user wanted turtle).

We this patch, the defaultValue is set in the state of the sent object, and the
form show the expect default value and this value is sent to the creation of the
dataservice. There is not validation error.

diff --git a/frontend/src/components/DataServiceForm.tsx b/frontend/src/components/DataServiceForm.tsx
--- a/frontend/src/components/DataServiceForm.tsx
+++ b/frontend/src/components/DataServiceForm.tsx
@@ -50,6 +50,7 @@
         ? dataService
         : {
             refresh_period: "weekly",
+            content_type: "text/turtle",
           },
     });
   const handleAuthErrors = useHandleAuthErrors();
@@ -143,7 +144,6 @@
                   label="Mimetype"
                   disabled={loading}
                   error={error !== undefined}
-                  defaultValue="text/turtle"
                   {...field}
                 >
                   <MenuItem value="application/rdf+xml">rdf-xml</MenuItem>
# HG changeset patch
# User Simon Chabot <simon.chabot@logilab.fr>
# Date 1714125324 -7200
#      Fri Apr 26 11:55:24 2024 +0200
# Node ID 947b722ff7fcabc13b647029920ecf3bb391e76d
# Parent  c1fb94f63665c700d414270d44dec6bfd614e86d
feat: add a scheduler task to check status of unfinished tasks


Task known has unfinished may have been killed, and the status will never be
updated. This looping_task will iterate over the unfinished task, and check
there status. If rq says they are failed or finished we update the entities.

related: #93

diff --git a/cubicweb_rodolf/hooks.py b/cubicweb_rodolf/hooks.py
--- a/cubicweb_rodolf/hooks.py
+++ b/cubicweb_rodolf/hooks.py
@@ -20,9 +20,10 @@
 from datetime import datetime, timedelta
 import logging
 
+import rq
 from cubicweb.server.hook import Hook, match_rtype
 from cubicweb.predicates import is_instance
-
+from cubicweb_rq.ccplugin import get_rq_redis_connection
 from cubicweb_s3storage.storages import S3Storage
 from rdflib import Graph
 
@@ -34,6 +35,10 @@
     seconds=float(os.getenv("RODOLF_IMPORT_DELTA", 60 * 60 * 24))
 )
 
+RODOLF_CHECK_TASK_STATUS_DELAY = timedelta(
+    seconds=float(os.getenv("RODOLF_CHECK_TASK_STATUS_DELAY", 60 * 2))
+)
+
 
 def looping_task_rodolf_import(repo):
     logger = logging.getLogger("rodolf-import-thread")
@@ -53,6 +58,36 @@
         )
 
 
+def looping_task_rodolf_check_tasks_status(repo):
+    repo.info("Check status of unfinished tasks")
+    redis_cnx_url = get_rq_redis_connection(repo.config.appid)
+    with repo.internal_cnx() as cnx, rq.Connection(redis_cnx_url):
+        # iterate over all unfinished task
+        rq_tasks = cnx.execute("Any X WHERE X is RqTask, X status NULL")
+        for rq_task in rq_tasks.entities():
+            rq_job = rq_task.cw_adapt_to("IRqJob")
+            import_process = rq_task.reverse_rq_task[0]
+            wf = import_process.cw_adapt_to("IWorkflowable")
+            status = rq_job.get_job().get_status()
+
+            if status == rq.job.JobStatus.FAILED:
+                repo.info("Task %s has failed", rq_task.eid)
+                # mark task and related import_process as failure
+                rq_job.handle_failure()
+                wf.fire_transition("fails")
+
+            elif status == rq.job.JobStatus.FINISHED:
+                # mark task and related import_process as success
+                repo.info("Task %s has finished", rq_task.eid)
+                rq_job.handle_finished()
+                wf.fire_transition("success")
+
+            else:
+                repo.debug("Task %s is ongoing.", rq_task.eid)
+
+        cnx.commit()
+
+
 class RodolfImportScheduler(Hook):
     __regid__ = "rodolf.server-startup-rodolf-import-hook"
     events = ("server_startup", "server_maintenance")
@@ -65,6 +100,12 @@
                 self.repo,
             )
 
+            self.repo.looping_task(
+                RODOLF_CHECK_TASK_STATUS_DELAY.total_seconds(),
+                looping_task_rodolf_check_tasks_status,
+                self.repo,
+            )
+
 
 class S3StorageStartupHook(Hook):
     __regid__ = "rodolf.server-startup-hook"