mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge the release into the backup health work and read the real dump health
The readiness model now takes the database dump's state from getDatabaseBackupHealth, and the dump test reaches the off-host key id where it moved.
This commit is contained in:
commit
e88dcd2c53
99 files changed
+6106
-492
No files matched your search
@@ -26,7 +26,11 @@ import { OffhostBackupsSection } from "../offhost-backups-section";
|
||||
const QUIET = {
|
||||
activity: { runningSince: null, interrupted: [] },
|
||||
pendingDeletions: { count: 0, oldestRequestedAt: null, lastFailure: null },
|
||||
lifecycle: { state: "configured" as const, expirationDays: 30 },
|
||||
lifecycle: {
|
||||
state: "configured" as const,
|
||||
expirationDays: 30,
|
||||
database: { state: "configured" as const, expirationDays: 30 },
|
||||
},
|
||||
};
|
||||
|
||||
function render(data: BackupsList["offhost"] | null): string {
|
||||
@@ -133,7 +137,11 @@ describe("<OffhostBackupsSection>", () => {
|
||||
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
|
||||
lastFailure: "AccessDenied",
|
||||
},
|
||||
lifecycle: { state: "missing", expirationDays: null },
|
||||
lifecycle: {
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
database: { state: "missing", expirationDays: null },
|
||||
},
|
||||
});
|
||||
expect(html).toContain('data-lifecycle-state="missing"');
|
||||
expect(html).toContain("no lifecycle rule");
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
|
||||
import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status";
|
||||
import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows";
|
||||
import { DatabaseDumpRow } from "./backups/database-dump-row";
|
||||
import { BackupUploadCard } from "./backups/backup-upload-card";
|
||||
import {
|
||||
RestoreRefusalNotice,
|
||||
@@ -98,7 +99,7 @@ export function BackupsSection() {
|
||||
{/* External docs link — `noopener noreferrer` because this
|
||||
leaves the admin shell. */}
|
||||
<a
|
||||
href="https://docs.healthlog.dev/admin/backups"
|
||||
href="https://docs.healthlog.dev/guides/backups/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||
@@ -110,6 +111,14 @@ export function BackupsSection() {
|
||||
}
|
||||
/>
|
||||
|
||||
{data?.database ? (
|
||||
<DatabaseDumpRow
|
||||
health={data.database}
|
||||
bucketLifecycle={
|
||||
data.offhost.configured ? data.offhost.lifecycle.database : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{data?.schedule ? (
|
||||
<ScheduleHealthNotice schedule={data.schedule} />
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The database row on the backups card says where the newest good dump is,
|
||||
* how long a deleted account stays in the dumps, and each thing that keeps a
|
||||
* dump from being taken or from ever ageing out of the bucket.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DatabaseBackupHealth } from "@/lib/database-backup/health";
|
||||
import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle";
|
||||
|
||||
import { DatabaseDumpRow } from "../database-dump-row";
|
||||
|
||||
const HEALTHY: DatabaseBackupHealth = {
|
||||
enabled: true,
|
||||
directory: "/var/lib/healthlog/backups",
|
||||
keepNightly: 7,
|
||||
localDirWritable: true,
|
||||
offhostConfigured: true,
|
||||
lastGood: {
|
||||
at: "2026-10-24T23:31:00.000Z",
|
||||
fileName: "healthlog-20261024T233000Z-daily.dump",
|
||||
sizeBytes: 52_428_800,
|
||||
local: true,
|
||||
offhost: true,
|
||||
},
|
||||
lastFailure: null,
|
||||
};
|
||||
|
||||
function render(
|
||||
health: DatabaseBackupHealth,
|
||||
bucketLifecycle: LifecycleVerdict | null = {
|
||||
state: "configured",
|
||||
expirationDays: 30,
|
||||
},
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DatabaseDumpRow health={health} bucketLifecycle={bucketLifecycle} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DatabaseDumpRow>", () => {
|
||||
it("names the newest good dump, where it is, and how long dumps keep deleted accounts", () => {
|
||||
const html = render(HEALTHY);
|
||||
expect(html).toContain("kept on this server and off-host");
|
||||
expect(html).toContain("the newest 7 are kept on this server");
|
||||
expect(html).toContain("removed after 30 days");
|
||||
expect(html).not.toContain('data-slot="database-dump-failed"');
|
||||
expect(html).not.toContain('data-slot="database-dump-setup"');
|
||||
expect(html).not.toContain('data-slot="database-dump-no-lifecycle"');
|
||||
});
|
||||
|
||||
it("warns when no bucket rule ever removes the dumps", () => {
|
||||
const html = render(HEALTHY, { state: "missing", expirationDays: null });
|
||||
expect(html).toContain('data-slot="database-dump-no-lifecycle"');
|
||||
expect(html).toContain("never removed");
|
||||
});
|
||||
|
||||
it("shows a failure newer than the last good dump, and not an older one", () => {
|
||||
const failed = render({
|
||||
...HEALTHY,
|
||||
lastFailure: {
|
||||
at: "2026-10-25T23:31:00.000Z",
|
||||
error: "pg_dump exited 1: No space left on device",
|
||||
},
|
||||
});
|
||||
expect(failed).toContain('data-slot="database-dump-failed"');
|
||||
expect(failed).toContain("No space left on device");
|
||||
const recovered = render({
|
||||
...HEALTHY,
|
||||
lastFailure: { at: "2026-10-20T23:31:00.000Z", error: "old" },
|
||||
});
|
||||
expect(recovered).not.toContain('data-slot="database-dump-failed"');
|
||||
});
|
||||
|
||||
it("says when dumps go off-host only, or cannot be taken at all", () => {
|
||||
expect(render({ ...HEALTHY, localDirWritable: false }, null)).toContain(
|
||||
"dumps go off-host only",
|
||||
);
|
||||
expect(
|
||||
render(
|
||||
{ ...HEALTHY, localDirWritable: false, offhostConfigured: false },
|
||||
null,
|
||||
),
|
||||
).toContain("no database dump can be taken");
|
||||
expect(render({ ...HEALTHY, lastGood: null }, null)).toContain(
|
||||
"No database dump yet",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The nightly whole-database dump, as one row on the backups card.
|
||||
*
|
||||
* The account copies below it restore one account on this server; the dump is
|
||||
* the copy that rebuilds the server. So the row leads the card and says three
|
||||
* things: when the newest good dump was taken and where it is, whether the
|
||||
* newest run failed, and whether this server can keep a dump at all.
|
||||
*/
|
||||
import { AlertTriangle, HardDrive } from "lucide-react";
|
||||
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { formatBytes } from "@/lib/format/bytes";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { DatabaseBackupHealth } from "@/lib/database-backup/health";
|
||||
import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle";
|
||||
|
||||
function whereKept(
|
||||
good: NonNullable<DatabaseBackupHealth["lastGood"]>,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
) {
|
||||
if (good.local && good.offhost) {
|
||||
return t("admin.section.backups.database.whereBoth");
|
||||
}
|
||||
if (good.offhost) return t("admin.section.backups.database.whereOffhost");
|
||||
return t("admin.section.backups.database.whereLocal");
|
||||
}
|
||||
|
||||
export function DatabaseDumpRow({
|
||||
health,
|
||||
bucketLifecycle,
|
||||
}: {
|
||||
health: DatabaseBackupHealth;
|
||||
/** The bucket's rule for `database/`; null when nothing goes off-host. */
|
||||
bucketLifecycle: LifecycleVerdict | null;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const good = health.lastGood;
|
||||
const failure = health.lastFailure;
|
||||
const failedSinceGood =
|
||||
failure !== null && (good === null || failure.at > good.at);
|
||||
|
||||
let setupProblem: string | null = null;
|
||||
if (!health.enabled) {
|
||||
setupProblem = t("admin.section.backups.database.disabled");
|
||||
} else if (!health.localDirWritable) {
|
||||
setupProblem = health.offhostConfigured
|
||||
? t("admin.section.backups.database.noDirectoryOffhost", {
|
||||
dir: health.directory,
|
||||
})
|
||||
: t("admin.section.backups.database.noDirectory", {
|
||||
dir: health.directory,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="database-dump">
|
||||
<div className="flex items-start gap-2">
|
||||
<HardDrive
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.section.backups.database.title")}
|
||||
</p>
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="database-dump-last-good"
|
||||
>
|
||||
{good
|
||||
? t("admin.section.backups.database.lastGood", {
|
||||
when: fmt.dateTime(good.at),
|
||||
size: formatBytes(good.sizeBytes ?? 0, locale),
|
||||
where: whereKept(good, t),
|
||||
})
|
||||
: t("admin.section.backups.database.none")}
|
||||
</p>
|
||||
{/* How long a deleted account stays in the dumps: the operator's
|
||||
side of the deletion promise (GDPR retention of backups). */}
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="database-dump-retention"
|
||||
>
|
||||
{t("admin.section.backups.database.retentionLocal", {
|
||||
nights: health.keepNightly,
|
||||
})}
|
||||
{bucketLifecycle?.state === "configured"
|
||||
? ` ${t("admin.section.backups.database.retentionOffhost", {
|
||||
days: bucketLifecycle.expirationDays ?? 0,
|
||||
})}`
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{failedSinceGood && failure ? (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="database-dump-failed"
|
||||
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle
|
||||
className="mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("admin.section.backups.database.failedTitle")}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{t("admin.section.backups.database.failedDescription", {
|
||||
when: fmt.dateTime(failure.at),
|
||||
reason: failure.error,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{bucketLifecycle?.state === "missing" ? (
|
||||
<SettingsInfoTile
|
||||
icon={AlertTriangle}
|
||||
tone="warning"
|
||||
role="alert"
|
||||
data-slot="database-dump-no-lifecycle"
|
||||
>
|
||||
{t("admin.section.backups.database.retentionOffhostMissing")}
|
||||
</SettingsInfoTile>
|
||||
) : null}
|
||||
|
||||
{setupProblem ? (
|
||||
<SettingsInfoTile
|
||||
icon={AlertTriangle}
|
||||
tone="warning"
|
||||
role="alert"
|
||||
data-slot="database-dump-setup"
|
||||
>
|
||||
{setupProblem}
|
||||
</SettingsInfoTile>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const record = vi.hoisted(() => ({ inSharedRecord: false }));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: record.inSharedRecord }),
|
||||
}));
|
||||
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
AiUnavailableHint,
|
||||
@@ -57,10 +62,26 @@ describe("<AiUnavailableHint>", () => {
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("nudges toward local OCR when a text-only provider needs it", () => {
|
||||
it("points at the on-device scan switch when a text-only provider needs it", () => {
|
||||
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
|
||||
expect(html).toContain("local OCR");
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
// The switch lives on the Labs module page, under its own label.
|
||||
expect(html).toContain("Read scans on this device");
|
||||
expect(html).toContain("Settings → Modules → Labs");
|
||||
expect(html).toContain('href="/settings/modules/labs#labs-local-ocr"');
|
||||
expect(html).toContain("Open lab settings");
|
||||
expect(html).not.toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("keeps the sentence but withholds the Settings link inside somebody else's record", () => {
|
||||
record.inSharedRecord = true;
|
||||
try {
|
||||
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
|
||||
expect(html).toContain("Read scans on this device");
|
||||
expect(html).not.toContain('data-slot="assist-settings-link"');
|
||||
expect(html).not.toContain('href="/settings');
|
||||
} finally {
|
||||
record.inSharedRecord = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const record = vi.hoisted(() => ({ inSharedRecord: false }));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: record.inSharedRecord }),
|
||||
}));
|
||||
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "../document-ai-section";
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { AiRunPhase } from "@/hooks/use-ai-run";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -64,9 +65,13 @@ export function AiRunPhaseNote({
|
||||
}
|
||||
|
||||
/**
|
||||
* Calm pointer to the AI settings, shown when assist is unavailable. When the
|
||||
* document is already searchable (auto-indexed locally) the copy stays honest —
|
||||
* it is searchable, an AI provider only adds a richer read.
|
||||
* Calm pointer to the setting that would make assist available: the AI
|
||||
* settings when no provider is configured, the on-device scan switch on the
|
||||
* Labs module page when the provider cannot read images. Both live under
|
||||
* `/settings`, which is the caller's own and closed inside somebody else's
|
||||
* record, so the link is withheld there and the sentence stands alone. When
|
||||
* the document is already searchable (auto-indexed locally) the copy stays
|
||||
* honest — it is searchable, an AI provider only adds a richer read.
|
||||
*/
|
||||
export function AiUnavailableHint({
|
||||
reason,
|
||||
@@ -74,10 +79,11 @@ export function AiUnavailableHint({
|
||||
reason: "no-provider" | "enable-local-ocr" | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const body =
|
||||
reason === "enable-local-ocr"
|
||||
? t("documents.assist.unavailableLocalOcr")
|
||||
: t("documents.assist.unavailableBody");
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const localOcr = reason === "enable-local-ocr";
|
||||
const body = localOcr
|
||||
? t("documents.assist.unavailableLocalOcr")
|
||||
: t("documents.assist.unavailableBody");
|
||||
return (
|
||||
<div
|
||||
data-slot="assist-unavailable"
|
||||
@@ -85,14 +91,25 @@ export function AiUnavailableHint({
|
||||
>
|
||||
<Sparkles className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">
|
||||
{body}{" "}
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="assist-settings-link"
|
||||
className="text-primary font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("documents.assist.unavailableAction")}
|
||||
</Link>
|
||||
{body}
|
||||
{!inSharedRecord && (
|
||||
<>
|
||||
{" "}
|
||||
<Link
|
||||
href={
|
||||
localOcr
|
||||
? "/settings/modules/labs#labs-local-ocr"
|
||||
: "/settings/ai"
|
||||
}
|
||||
data-slot="assist-settings-link"
|
||||
className="text-primary font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{localOcr
|
||||
? t("documents.assist.unavailableLocalOcrAction")
|
||||
: t("documents.assist.unavailableAction")}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -251,8 +251,13 @@ describe("IntegrationsSection — one status display per card", () => {
|
||||
for (const provider of PROVIDERS) {
|
||||
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
|
||||
expect(html).toContain(
|
||||
`href="https://docs.healthlog.dev/integrations/${provider}"`,
|
||||
`href="https://docs.healthlog.dev/guides/connect-${provider}/"`,
|
||||
);
|
||||
}
|
||||
// Garmin has no connector; its note goes straight to the section of the
|
||||
// Google Health guide that explains the route, not through a redirect.
|
||||
expect(html).toContain(
|
||||
'href="https://docs.healthlog.dev/guides/connect-google-health/#garmin-owners"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,7 @@ describe("<ManagedIntegrationStatus>", () => {
|
||||
"Services you connect here send their data to Managed record's record, not to yours.",
|
||||
);
|
||||
expect(html).toContain(
|
||||
'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"',
|
||||
'href="https://docs.healthlog.dev/guides/managed-profiles/#connected-services"',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("<FitbitRetirementNotice>", () => {
|
||||
expect(html).toContain("Google Health reads the same Fitbit and Pixel");
|
||||
expect(html).toContain("Everything already imported stays.");
|
||||
expect(html).toContain('href="#google-health"');
|
||||
expect(html).toContain("/google-health");
|
||||
expect(html).toContain("/guides/connect-google-health/");
|
||||
});
|
||||
|
||||
it("says the connection has stopped once the day has come", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ExternalLink, Watch } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { INTEGRATION_DOCS_BASE } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { GARMIN_DOCS_HREF } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function GarminInfoNote() {
|
||||
@@ -25,7 +25,7 @@ export function GarminInfoNote() {
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.garminInfo.body")}{" "}
|
||||
<a
|
||||
href={`${INTEGRATION_DOCS_BASE}/garmin`}
|
||||
href={GARMIN_DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="garmin-docs-link"
|
||||
|
||||
@@ -17,11 +17,16 @@ import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Base for every provider setup runbook. The provider key is appended as a
|
||||
* path segment, e.g. `https://docs.healthlog.dev/integrations/whoop`. Kept as
|
||||
* a single constant so the host never drifts across cards.
|
||||
* The docs site's guides, where every provider runbook lives at
|
||||
* `connect-<provider>/`, e.g. `https://docs.healthlog.dev/guides/connect-whoop/`.
|
||||
* These are the pages themselves: the older `/integrations/<provider>` paths
|
||||
* only resolve through redirects. Kept as a single constant so the host never
|
||||
* drifts across cards.
|
||||
*/
|
||||
export const INTEGRATION_DOCS_BASE = "https://docs.healthlog.dev/integrations";
|
||||
export const DOCS_GUIDES_BASE = "https://docs.healthlog.dev/guides";
|
||||
|
||||
/** Garmin has no connector; the Google Health guide explains the route. */
|
||||
export const GARMIN_DOCS_HREF = `${DOCS_GUIDES_BASE}/connect-google-health/#garmin-owners`;
|
||||
|
||||
export type IntegrationDocsProvider =
|
||||
| "whoop"
|
||||
@@ -30,15 +35,12 @@ export type IntegrationDocsProvider =
|
||||
| "polar"
|
||||
| "oura"
|
||||
| "nightscout"
|
||||
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
|
||||
| "google-health"
|
||||
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
|
||||
| "strava"
|
||||
// v1.43 — Dawarich runbook (docs.healthlog.dev/integrations/dawarich).
|
||||
| "dawarich";
|
||||
|
||||
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
|
||||
return `${INTEGRATION_DOCS_BASE}/${provider}`;
|
||||
return `${DOCS_GUIDES_BASE}/connect-${provider}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,7 +54,7 @@ const INTEGRATION_DISPLAY_NAMES = {
|
||||
|
||||
/** Where the guardian boundary is explained, connected services included. */
|
||||
const SHARING_DOCS_HREF =
|
||||
"https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login";
|
||||
"https://docs.healthlog.dev/guides/managed-profiles/#connected-services";
|
||||
|
||||
function iconFor(key: ManagedIntegrationKey) {
|
||||
if (key === "nightscout") return Droplet;
|
||||
|
||||
Reference in new issue
Block a user