Skip to content

Commit 7d42679

Browse files
authored
Store integration trigger result (#1247)
* feat(migrations): add column for trigger result * feat(migrations): add default value to trigger result for integrations * feat(backend): adapt to new db schema * feat(backend): store integration trigger result * feat(migrations): drop the last trigger on column * feat(backend): change names of attributes * feat(services/integration): handle when the sync itself fails * feat(services/integration): store only 20 elements in the queue * Merge branch 'main' into issue-1232 * feat(services/integration): use more efficient structure for trigger results * chore(frontend): remove ref to invalid integration column * chore(frontend): add separator between first row elements * chore(frontend): add empty fragment * fix(frontend): do not show paused if it is not * feat(frontend): add btn to show integration trigger result * refactor(frontend): change name of var * feat(frontend): add modal for trigger result display * feat(frontend): display integration logs in a table * chore(frontend): remove useless text in header * docs: add information about automatic disabling of integrations * feat(migrations): add column for last finished at to integration table * feat(backend): store the last finished at date for integration * feat(frontend): display last finished on * feat(migrations): set the last triggered at date correctly for integrations * fix(frontend): change the font sizes to be consistent * ci: Run CI * chore(frontend): remove useless map * refactor(services/integration): common function to select integrations * fix(migrations): handle cases when integration has never been triggered * chore(services/integration): order by created on when selecting integrations * feat(services/misc): add fn to mark integrations as disabled * feat(backend): add new notification type * feat(frontend): allow disabling new integration * feat(services/misc): complete implementation of automatically disabling integrations * ci: Run CI * refactor(services/misc): do not declare separate integrations
1 parent c320391 commit 7d42679

File tree

15 files changed

+395
-164
lines changed

15 files changed

+395
-164
lines changed

apps/frontend/app/routes/_dashboard.settings.integrations.tsx

Lines changed: 109 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
Paper,
1717
Select,
1818
Stack,
19+
Table,
20+
type TableData,
1921
Text,
2022
TextInput,
2123
Title,
@@ -46,7 +48,7 @@ import {
4648
IconPencil,
4749
IconTrash,
4850
} from "@tabler/icons-react";
49-
import { useState } from "react";
51+
import { type ReactNode, useState } from "react";
5052
import { Form, data, useActionData, useLoaderData } from "react-router";
5153
import { match } from "ts-pattern";
5254
import { withQuery } from "ufo";
@@ -326,88 +328,124 @@ const DisplayIntegration = (props: {
326328
setUpdateIntegrationModalData: (data: Integration | null) => void;
327329
}) => {
328330
const [parent] = useAutoAnimate();
329-
const [integrationInputOpened, { toggle: integrationInputToggle }] =
331+
const [integrationUrlOpened, { toggle: integrationUrlToggle }] =
330332
useDisclosure(false);
333+
const [
334+
integrationTriggerResultOpened,
335+
{ toggle: integrationTriggerResultToggle },
336+
] = useDisclosure(false);
331337
const submit = useConfirmSubmit();
332338

333339
const integrationUrl = `${applicationBaseUrl}/_i/${props.integration.id}`;
334340

341+
const firstRow = [
342+
<Text size="sm" fw="bold" key="name">
343+
{props.integration.name || changeCase(props.integration.provider)}
344+
</Text>,
345+
props.integration.isDisabled ? (
346+
<Text size="sm" key="isPaused">
347+
Paused
348+
</Text>
349+
) : undefined,
350+
props.integration.triggerResult.length > 0 ? (
351+
<Anchor
352+
size="sm"
353+
key="triggerResult"
354+
onClick={() => integrationTriggerResultToggle()}
355+
>
356+
Show logs
357+
</Anchor>
358+
) : undefined,
359+
]
360+
.filter(Boolean)
361+
.map<ReactNode>((s) => s)
362+
.reduce((prev, curr) => [prev, " • ", curr]);
363+
364+
const tableData: TableData = {
365+
head: ["Triggered At", "Error"],
366+
body: props.integration.triggerResult.map((tr) => [
367+
dayjsLib(tr.finishedAt).format("lll"),
368+
tr.error || "N/A",
369+
]),
370+
};
371+
335372
return (
336-
<Paper p="xs" withBorder>
337-
<Stack ref={parent}>
338-
<Flex align="center" justify="space-between">
339-
<Box>
340-
<Group gap={4}>
341-
<Text size="sm" fw="bold">
342-
{props.integration.name ||
343-
changeCase(props.integration.provider)}
344-
</Text>
345-
{props.integration.isDisabled ? (
346-
<Text size="xs">(Paused)</Text>
347-
) : null}
348-
</Group>
349-
<Text size="xs">
350-
Created: {dayjsLib(props.integration.createdOn).fromNow()}
351-
</Text>
352-
{props.integration.lastTriggeredOn ? (
373+
<>
374+
<Modal
375+
withCloseButton={false}
376+
opened={integrationTriggerResultOpened}
377+
onClose={() => integrationTriggerResultToggle()}
378+
>
379+
<Table data={tableData} />
380+
</Modal>
381+
<Paper p="xs" withBorder>
382+
<Stack ref={parent}>
383+
<Flex align="center" justify="space-between">
384+
<Box>
385+
<Group gap={4}>{firstRow}</Group>
353386
<Text size="xs">
354-
Triggered:{" "}
355-
{dayjsLib(props.integration.lastTriggeredOn).fromNow()}
387+
Created: {dayjsLib(props.integration.createdOn).fromNow()}
356388
</Text>
357-
) : null}
358-
{props.integration.syncToOwnedCollection ? (
359-
<Text size="xs">Being synced to "Owned" collection</Text>
360-
) : null}
361-
</Box>
362-
<Group>
363-
{!NO_SHOW_URL.includes(props.integration.provider) ? (
364-
<ActionIcon color="blue" onClick={integrationInputToggle}>
365-
{integrationInputOpened ? <IconEyeClosed /> : <IconEye />}
366-
</ActionIcon>
367-
) : null}
368-
<ActionIcon
369-
color="indigo"
370-
variant="subtle"
371-
onClick={() =>
372-
props.setUpdateIntegrationModalData(props.integration)
373-
}
374-
>
375-
<IconPencil />
376-
</ActionIcon>
377-
<Form method="POST" action={withQuery(".", { intent: "delete" })}>
378-
<input
379-
type="hidden"
380-
name="integrationId"
381-
defaultValue={props.integration.id}
382-
/>
389+
{props.integration.lastFinishedAt ? (
390+
<Text size="xs">
391+
Last finished:{" "}
392+
{dayjsLib(props.integration.lastFinishedAt).fromNow()}
393+
</Text>
394+
) : null}
395+
{props.integration.syncToOwnedCollection ? (
396+
<Text size="xs">Being synced to "Owned" collection</Text>
397+
) : null}
398+
</Box>
399+
<Group>
400+
{!NO_SHOW_URL.includes(props.integration.provider) ? (
401+
<ActionIcon color="blue" onClick={integrationUrlToggle}>
402+
{integrationUrlOpened ? <IconEyeClosed /> : <IconEye />}
403+
</ActionIcon>
404+
) : null}
383405
<ActionIcon
384-
type="submit"
385-
color="red"
406+
color="indigo"
386407
variant="subtle"
387-
mt={4}
388-
onClick={(e) => {
389-
const form = e.currentTarget.form;
390-
e.preventDefault();
391-
openConfirmationModal(
392-
"Are you sure you want to delete this integration?",
393-
() => submit(form),
394-
);
395-
}}
408+
onClick={() =>
409+
props.setUpdateIntegrationModalData(props.integration)
410+
}
396411
>
397-
<IconTrash />
412+
<IconPencil />
398413
</ActionIcon>
399-
</Form>
400-
</Group>
401-
</Flex>
402-
{integrationInputOpened ? (
403-
<TextInput
404-
value={integrationUrl}
405-
readOnly
406-
onClick={(e) => e.currentTarget.select()}
407-
/>
408-
) : null}
409-
</Stack>
410-
</Paper>
414+
<Form method="POST" action={withQuery(".", { intent: "delete" })}>
415+
<input
416+
type="hidden"
417+
name="integrationId"
418+
defaultValue={props.integration.id}
419+
/>
420+
<ActionIcon
421+
type="submit"
422+
color="red"
423+
variant="subtle"
424+
mt={4}
425+
onClick={(e) => {
426+
const form = e.currentTarget.form;
427+
e.preventDefault();
428+
openConfirmationModal(
429+
"Are you sure you want to delete this integration?",
430+
() => submit(form),
431+
);
432+
}}
433+
>
434+
<IconTrash />
435+
</ActionIcon>
436+
</Form>
437+
</Group>
438+
</Flex>
439+
{integrationUrlOpened ? (
440+
<TextInput
441+
value={integrationUrl}
442+
readOnly
443+
onClick={(e) => e.currentTarget.select()}
444+
/>
445+
) : null}
446+
</Stack>
447+
</Paper>
448+
</>
411449
);
412450
};
413451

apps/frontend/app/routes/_dashboard.settings.preferences.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,10 @@ export default function Page() {
512512
UserNotificationContent.NewWorkoutCreated,
513513
() => "A new workout is created",
514514
)
515+
.with(
516+
UserNotificationContent.IntegrationDisabledDueToTooManyErrors,
517+
() => "Integration disabled due to too many errors",
518+
)
515519
.exhaustive()}
516520
/>
517521
))}

crates/migrations/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ mod m20250201_changes_for_issue_1211;
3333
mod m20250204_changes_for_issue_1231;
3434
mod m20250208_changes_for_issue_1233;
3535
mod m20250210_changes_for_issue_1217;
36+
mod m20250210_changes_for_issue_1232;
3637

3738
pub use m20230404_create_user::User as AliasedUser;
3839
pub use m20230410_create_metadata::Metadata as AliasedMetadata;
@@ -87,6 +88,7 @@ impl MigratorTrait for Migrator {
8788
Box::new(m20250204_changes_for_issue_1231::Migration),
8889
Box::new(m20250208_changes_for_issue_1233::Migration),
8990
Box::new(m20250210_changes_for_issue_1217::Migration),
91+
Box::new(m20250210_changes_for_issue_1232::Migration),
9092
]
9193
}
9294
}

crates/migrations/src/m20240607_create_integration.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ pub enum Integration {
1515
Provider,
1616
CreatedOn,
1717
IsDisabled,
18+
TriggerResult,
19+
LastFinishedAt,
1820
MinimumProgress,
1921
MaximumProgress,
20-
LastTriggeredOn,
2122
ProviderSpecifics,
2223
SyncToOwnedCollection,
2324
}
@@ -43,7 +44,13 @@ impl MigrationTrait for Migration {
4344
.not_null()
4445
.default(Expr::current_timestamp()),
4546
)
46-
.col(ColumnDef::new(Integration::LastTriggeredOn).timestamp_with_time_zone())
47+
.col(
48+
ColumnDef::new(Integration::TriggerResult)
49+
.json_binary()
50+
.not_null()
51+
.default("[]"),
52+
)
53+
.col(ColumnDef::new(Integration::LastFinishedAt).timestamp_with_time_zone())
4754
.col(ColumnDef::new(Integration::ProviderSpecifics).json_binary())
4855
.col(ColumnDef::new(Integration::UserId).text().not_null())
4956
.col(ColumnDef::new(Integration::MinimumProgress).decimal())
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use sea_orm_migration::prelude::*;
2+
3+
#[derive(DeriveMigrationName)]
4+
pub struct Migration;
5+
6+
#[async_trait::async_trait]
7+
impl MigrationTrait for Migration {
8+
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9+
let db = manager.get_connection();
10+
if !manager.has_column("integration", "trigger_result").await? {
11+
db.execute_unprepared(
12+
r#"
13+
ALTER TABLE "integration" ADD COLUMN "trigger_result" JSONB NOT NULL DEFAULT '[]'::JSONB;
14+
15+
UPDATE "integration" i SET "trigger_result" = JSONB_BUILD_ARRAY(JSONB_BUILD_OBJECT('finished_at', i."last_triggered_on"))
16+
WHERE i."last_triggered_on" IS NOT NULL;
17+
"#,
18+
)
19+
.await?;
20+
}
21+
if !manager
22+
.has_column("integration", "last_finished_at")
23+
.await?
24+
{
25+
db.execute_unprepared(
26+
r#"
27+
ALTER TABLE "integration" ADD COLUMN "last_finished_at" TIMESTAMPTZ;
28+
29+
UPDATE "integration" i SET "last_finished_at" = i."last_triggered_on"
30+
WHERE i."last_triggered_on" IS NOT NULL;
31+
"#,
32+
)
33+
.await?;
34+
}
35+
if manager
36+
.has_column("integration", "last_triggered_on")
37+
.await?
38+
{
39+
db.execute_unprepared(r#"ALTER TABLE "integration" DROP COLUMN "last_triggered_on";"#)
40+
.await?;
41+
}
42+
db.execute_unprepared(
43+
r#"
44+
UPDATE
45+
"user"
46+
SET
47+
preferences = JSONB_SET(
48+
preferences,
49+
'{notifications,to_send}',
50+
(preferences -> 'notifications' -> 'to_send') || '"IntegrationDisabledDueToTooManyErrors"'
51+
)
52+
where
53+
NOT (
54+
preferences -> 'notifications' -> 'to_send' ? 'IntegrationDisabledDueToTooManyErrors'
55+
);
56+
"#,
57+
)
58+
.await?;
59+
Ok(())
60+
}
61+
62+
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
63+
Ok(())
64+
}
65+
}

crates/models/common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ pub enum UserNotificationContent {
201201
PersonMetadataGroupAssociated,
202202
MetadataNumberOfSeasonsChanged,
203203
MetadataChaptersOrEpisodesChanged,
204+
IntegrationDisabledDueToTooManyErrors,
204205
}
205206

206207
#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)]

crates/models/database/src/integration.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use async_graphql::{InputObject, SimpleObject};
44
use async_trait::async_trait;
55
use enum_models::{IntegrationLot, IntegrationProvider};
6-
use media_models::IntegrationProviderSpecifics;
6+
use media_models::{IntegrationProviderSpecifics, IntegrationTriggerResult};
77
use nanoid::nanoid;
88
use sea_orm::{entity::prelude::*, ActiveValue};
99

@@ -24,9 +24,11 @@ pub struct Model {
2424
pub provider: IntegrationProvider,
2525
pub minimum_progress: Option<Decimal>,
2626
pub maximum_progress: Option<Decimal>,
27+
pub last_finished_at: Option<DateTimeUtc>,
2728
pub sync_to_owned_collection: Option<bool>,
29+
#[sea_orm(column_type = "Json")]
2830
#[graphql(skip_input)]
29-
pub last_triggered_on: Option<DateTimeUtc>,
31+
pub trigger_result: Vec<IntegrationTriggerResult>,
3032
#[sea_orm(column_type = "Json")]
3133
#[graphql(skip)]
3234
pub provider_specifics: Option<IntegrationProviderSpecifics>,

crates/models/media/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,15 @@ pub struct PersonStateChanges {
834834
pub metadata_groups_associated: HashSet<MediaAssociatedPersonStateChanges>,
835835
}
836836

837+
#[skip_serializing_none]
838+
#[derive(
839+
Debug, Serialize, Deserialize, Clone, FromJsonQueryResult, Eq, PartialEq, Default, SimpleObject,
840+
)]
841+
pub struct IntegrationTriggerResult {
842+
pub error: Option<String>,
843+
pub finished_at: DateTimeUtc,
844+
}
845+
837846
#[skip_serializing_none]
838847
#[derive(
839848
Debug,

0 commit comments

Comments
 (0)