mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(admin): say how long dumps keep deleted accounts, and check the bucket rule for database/
A database dump is the whole database at one moment, so an account deleted since stays in the dumps taken before until they age out. The database row on the backups card now says so with the numbers that decide it: how many nightly dumps this server keeps, and after how many days the bucket's lifecycle rule removes the off-host copies. The lifecycle probe used to answer with the shortest expiry of any enabled rule, whatever its prefix, so a rule scoped to the account copies read as covering the dumps too. It now also answers for `database/` alone, counting only rules whose filter takes in a dump's key (prefix, legacy prefix, an And filter; a tag filter never matches untagged objects), and the card warns when no rule covers that prefix: dumps there, and the deleted accounts in them, would never be removed. The reading moved into its own module.
This commit is contained in:
1 parent
82db3de30a
commit
e825a4c4d3
20 files changed
+355
-47
No files matched your search
@@ -54,12 +54,14 @@ vi.mock("@/lib/jobs/offhost-backup", () => ({
|
||||
probeOffhostLifecycle: async () => ({
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
database: { state: "missing", expirationDays: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
const databaseHealth = {
|
||||
enabled: true,
|
||||
directory: "/var/lib/healthlog/backups",
|
||||
keepNightly: 7,
|
||||
localDirWritable: true,
|
||||
offhostConfigured: true,
|
||||
lastGood: {
|
||||
@@ -184,7 +186,11 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
// No bucket to ask on a host that is not configured.
|
||||
lifecycle: { state: "unknown", expirationDays: null },
|
||||
lifecycle: {
|
||||
state: "unknown",
|
||||
expirationDays: null,
|
||||
database: { state: "unknown", expirationDays: null },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,6 +253,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
expect(body.offhost.lifecycle).toEqual({
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
database: { state: "missing", expirationDays: null },
|
||||
});
|
||||
expect(body.offhost.pendingDeletions).toEqual({
|
||||
count: 2,
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
probeOffhostLifecycle,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue";
|
||||
import { LIFECYCLE_UNKNOWN } from "@/lib/jobs/offhost-lifecycle";
|
||||
import {
|
||||
classifyOffhostBackup,
|
||||
OFFHOST_BACKUP_PERIOD_HOURS,
|
||||
@@ -202,9 +203,7 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: { requestedAt: "asc" },
|
||||
select: { requestedAt: true, lastFailure: true },
|
||||
}),
|
||||
configured
|
||||
? probeOffhostLifecycle()
|
||||
: Promise.resolve({ state: "unknown" as const, expirationDays: null }),
|
||||
configured ? probeOffhostLifecycle() : Promise.resolve(LIFECYCLE_UNKNOWN),
|
||||
getDatabaseBackupHealth(now),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -111,7 +111,14 @@ export function BackupsSection() {
|
||||
}
|
||||
/>
|
||||
|
||||
{data?.database ? <DatabaseDumpRow health={data.database} /> : null}
|
||||
{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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ 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"]>,
|
||||
@@ -26,7 +27,14 @@ function whereKept(
|
||||
return t("admin.section.backups.database.whereLocal");
|
||||
}
|
||||
|
||||
export function DatabaseDumpRow({ health }: { health: DatabaseBackupHealth }) {
|
||||
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;
|
||||
@@ -70,6 +78,21 @@ export function DatabaseDumpRow({ health }: { health: DatabaseBackupHealth }) {
|
||||
})
|
||||
: 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>
|
||||
|
||||
@@ -99,6 +122,17 @@ export function DatabaseDumpRow({ health }: { health: DatabaseBackupHealth }) {
|
||||
</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}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
databaseBackupEnabled,
|
||||
isWritableDirectory,
|
||||
resolveBackupDir,
|
||||
resolveDailyKeep,
|
||||
} from "./dump-core.mjs";
|
||||
|
||||
export interface DatabaseBackupHealth {
|
||||
@@ -23,6 +24,12 @@ export interface DatabaseBackupHealth {
|
||||
enabled: boolean;
|
||||
/** The directory dumps are kept in (`DB_BACKUP_DIR`). */
|
||||
directory: string;
|
||||
/**
|
||||
* Nightly dumps kept in that directory (`DB_BACKUP_KEEP`). Also how long a
|
||||
* deleted account stays in the dumps on this server, which the backups
|
||||
* page says.
|
||||
*/
|
||||
keepNightly: number;
|
||||
/** This process can write there. False means dumps only go off-host. */
|
||||
localDirWritable: boolean;
|
||||
/** The off-host bucket is configured, or a dump has been uploaded to it. */
|
||||
@@ -100,6 +107,7 @@ export async function getDatabaseBackupHealth(
|
||||
return {
|
||||
enabled: databaseBackupEnabled(env),
|
||||
directory,
|
||||
keepNightly: resolveDailyKeep(env),
|
||||
localDirWritable: isWritableDirectory(directory),
|
||||
offhostConfigured: offhostBackupConfigured() || everUploaded > 0,
|
||||
lastGood: lastGood
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Which lifecycle rules retire the database dumps under `database/`, apart
|
||||
* from the account copies: a rule scoped to another prefix, or to a tag, does
|
||||
* not touch them.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LifecycleRule } from "@aws-sdk/client-s3";
|
||||
|
||||
import { lifecycleFromRules, ruleAppliesTo } from "../offhost-lifecycle";
|
||||
|
||||
const DUMP = "database/healthlog-20261025T013000Z-daily.dump.enc";
|
||||
|
||||
function rule(days: number, extra: Partial<LifecycleRule> = {}): LifecycleRule {
|
||||
return { Status: "Enabled", Expiration: { Days: days }, ...extra };
|
||||
}
|
||||
|
||||
describe("lifecycle rules and the database dumps", () => {
|
||||
it("counts a rule for the whole bucket, or for database/, for both", () => {
|
||||
expect(lifecycleFromRules([rule(30)])).toEqual({
|
||||
state: "configured",
|
||||
expirationDays: 30,
|
||||
database: { state: "configured", expirationDays: 30 },
|
||||
});
|
||||
expect(
|
||||
lifecycleFromRules([
|
||||
rule(30, { Filter: { Prefix: "2026-" } }),
|
||||
rule(14, { Filter: { Prefix: "database/" } }),
|
||||
]).database,
|
||||
).toEqual({ state: "configured", expirationDays: 14 });
|
||||
});
|
||||
|
||||
it("says database/ is not covered when the only rule is scoped elsewhere", () => {
|
||||
const result = lifecycleFromRules([
|
||||
rule(30, { Filter: { Prefix: "2026-" } }),
|
||||
]);
|
||||
expect(result.state).toBe("configured");
|
||||
expect(result.database).toEqual({ state: "missing", expirationDays: null });
|
||||
});
|
||||
|
||||
it("reads the legacy prefix, the And filter, tags and disabled rules", () => {
|
||||
expect(ruleAppliesTo(rule(5, { Prefix: "database/" }), DUMP)).toBe(true);
|
||||
expect(ruleAppliesTo(rule(5, { Prefix: "other/" }), DUMP)).toBe(false);
|
||||
expect(
|
||||
ruleAppliesTo(
|
||||
rule(5, {
|
||||
Filter: { And: { Prefix: "database/", ObjectSizeGreaterThan: 1 } },
|
||||
}),
|
||||
DUMP,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
ruleAppliesTo(
|
||||
rule(5, { Filter: { Tag: { Key: "k", Value: "v" } } }),
|
||||
DUMP,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
lifecycleFromRules([rule(7, { Status: "Disabled" })]).database.state,
|
||||
).toBe("missing");
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,12 @@ import {
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { envValue } from "@/lib/env";
|
||||
import { OFFHOST_BACKUP_QUEUE } from "./offhost-backup-queue";
|
||||
import {
|
||||
LIFECYCLE_MISSING,
|
||||
LIFECYCLE_UNKNOWN,
|
||||
lifecycleFromRules,
|
||||
type OffhostLifecycle,
|
||||
} from "./offhost-lifecycle";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
@@ -634,18 +640,7 @@ export interface S3Like {
|
||||
getLifecycle?(): Promise<OffhostLifecycle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bucket retires old copies by itself.
|
||||
*
|
||||
* `configured` names the shortest expiry of an enabled rule; `missing` means
|
||||
* the bucket answered that it has no rule; `unknown` means it would not say
|
||||
* (a credential without `s3:GetLifecycleConfiguration`, a target that does not
|
||||
* implement the call, a network failure) and says nothing either way.
|
||||
*/
|
||||
export interface OffhostLifecycle {
|
||||
state: "configured" | "missing" | "unknown";
|
||||
expirationDays: number | null;
|
||||
}
|
||||
export type { OffhostLifecycle } from "./offhost-lifecycle";
|
||||
|
||||
export async function getS3Client(cfg: OffhostBackupConfig): Promise<S3Like> {
|
||||
// Dynamic import so unit tests + dev environments without the SDK don't fail.
|
||||
@@ -765,13 +760,7 @@ export async function getS3Client(cfg: OffhostBackupConfig): Promise<S3Like> {
|
||||
}),
|
||||
{ abortSignal: AbortSignal.timeout(5_000) },
|
||||
);
|
||||
const days = (out.Rules ?? [])
|
||||
.filter((rule) => rule.Status === "Enabled")
|
||||
.map((rule) => rule.Expiration?.Days)
|
||||
.filter((d): d is number => typeof d === "number" && d > 0);
|
||||
return days.length > 0
|
||||
? { state: "configured", expirationDays: Math.min(...days) }
|
||||
: { state: "missing", expirationDays: null };
|
||||
return lifecycleFromRules(out.Rules ?? []);
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string; Code?: string }).name;
|
||||
const code = (err as { Code?: string }).Code;
|
||||
@@ -779,9 +768,9 @@ export async function getS3Client(cfg: OffhostBackupConfig): Promise<S3Like> {
|
||||
name === "NoSuchLifecycleConfiguration" ||
|
||||
code === "NoSuchLifecycleConfiguration"
|
||||
) {
|
||||
return { state: "missing", expirationDays: null };
|
||||
return LIFECYCLE_MISSING;
|
||||
}
|
||||
return { state: "unknown", expirationDays: null };
|
||||
return LIFECYCLE_UNKNOWN;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -806,15 +795,13 @@ export async function probeOffhostLifecycle(
|
||||
return lifecycleCache.value;
|
||||
}
|
||||
const cfg = loadOffhostConfigSafe();
|
||||
if (!cfg) return { state: "unknown", expirationDays: null };
|
||||
if (!cfg) return LIFECYCLE_UNKNOWN;
|
||||
let value: OffhostLifecycle;
|
||||
try {
|
||||
const s3 = s3Override ?? (await getS3Client(cfg));
|
||||
value = s3.getLifecycle
|
||||
? await s3.getLifecycle()
|
||||
: { state: "unknown", expirationDays: null };
|
||||
value = s3.getLifecycle ? await s3.getLifecycle() : LIFECYCLE_UNKNOWN;
|
||||
} catch {
|
||||
value = { state: "unknown", expirationDays: null };
|
||||
value = LIFECYCLE_UNKNOWN;
|
||||
}
|
||||
if (!s3Override) lifecycleCache = { at: now, value };
|
||||
return value;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Whether the operator's bucket retires old copies by itself, read from its
|
||||
* lifecycle rules (`GetBucketLifecycleConfiguration`).
|
||||
*
|
||||
* Two answers, because the bucket holds two kinds of object with different
|
||||
* keys: the per-account copies (`<date>/user-<id>.json.enc`) and the database
|
||||
* dumps (`database/…`). A rule scoped to a prefix covers only the keys under
|
||||
* it, so a bucket whose one rule expires `2026-` keys ages the account copies
|
||||
* out and keeps every database dump forever, and a database dump keeps every
|
||||
* deleted account's data for as long as it exists. The account answer keeps
|
||||
* its earlier reading (the shortest expiry of any enabled rule); the database
|
||||
* answer counts only the rules that apply to a key under `database/`.
|
||||
*
|
||||
* `configured` names the shortest expiry; `missing` means the bucket answered
|
||||
* that no rule applies; `unknown` means it would not say (a credential
|
||||
* without `s3:GetLifecycleConfiguration`, a target that does not implement the
|
||||
* call, a network failure) and says nothing either way.
|
||||
*/
|
||||
import type { LifecycleRule } from "@aws-sdk/client-s3";
|
||||
|
||||
export interface LifecycleVerdict {
|
||||
state: "configured" | "missing" | "unknown";
|
||||
expirationDays: number | null;
|
||||
}
|
||||
|
||||
export interface OffhostLifecycle extends LifecycleVerdict {
|
||||
/** The same question for the database dumps under `database/`. */
|
||||
database: LifecycleVerdict;
|
||||
}
|
||||
|
||||
export const LIFECYCLE_UNKNOWN: OffhostLifecycle = {
|
||||
state: "unknown",
|
||||
expirationDays: null,
|
||||
database: { state: "unknown", expirationDays: null },
|
||||
};
|
||||
|
||||
export const LIFECYCLE_MISSING: OffhostLifecycle = {
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
database: { state: "missing", expirationDays: null },
|
||||
};
|
||||
|
||||
/** A key a database dump is stored under, to test the rules against. */
|
||||
const DATABASE_SAMPLE_KEY =
|
||||
"database/healthlog-20260101T013000Z-daily.dump.enc";
|
||||
|
||||
/**
|
||||
* Whether a rule's filter takes in `key`. Our objects carry no tags, so a
|
||||
* rule that filters on a tag never applies to them; a size bound is not a
|
||||
* reason to call a rule inapplicable, so it is read as applying.
|
||||
*/
|
||||
export function ruleAppliesTo(rule: LifecycleRule, key: string): boolean {
|
||||
const filter = rule.Filter;
|
||||
if (filter?.Tag || filter?.And?.Tags?.length) return false;
|
||||
const prefix = filter?.Prefix ?? filter?.And?.Prefix ?? rule.Prefix ?? "";
|
||||
return key.startsWith(prefix);
|
||||
}
|
||||
|
||||
function verdict(rules: readonly LifecycleRule[]): LifecycleVerdict {
|
||||
const days = rules
|
||||
.filter((rule) => rule.Status === "Enabled")
|
||||
.map((rule) => rule.Expiration?.Days)
|
||||
.filter((d): d is number => typeof d === "number" && d > 0);
|
||||
return days.length > 0
|
||||
? { state: "configured", expirationDays: Math.min(...days) }
|
||||
: { state: "missing", expirationDays: null };
|
||||
}
|
||||
|
||||
/** Both answers from the rules a bucket returned. */
|
||||
export function lifecycleFromRules(
|
||||
rules: readonly LifecycleRule[],
|
||||
): OffhostLifecycle {
|
||||
return {
|
||||
...verdict(rules),
|
||||
database: verdict(
|
||||
rules.filter((rule) => ruleAppliesTo(rule, DATABASE_SAMPLE_KEY)),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
* keeps owning the HTTP contract, the component keeps owning the UI.
|
||||
*/
|
||||
import type { DatabaseBackupHealth } from "@/lib/database-backup/health";
|
||||
import type { OffhostLifecycle } from "@/lib/jobs/offhost-lifecycle";
|
||||
import type { BackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
|
||||
import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status";
|
||||
import type { OffhostBackupFreshness } from "@/lib/jobs/offhost-backup-freshness";
|
||||
@@ -77,10 +78,7 @@ export interface OffhostBackupOverview {
|
||||
* (`GetBucketLifecycleConfiguration`), cached for ten minutes; `unknown`
|
||||
* when the credential may not read it.
|
||||
*/
|
||||
lifecycle: {
|
||||
state: "configured" | "missing" | "unknown";
|
||||
expirationDays: number | null;
|
||||
};
|
||||
lifecycle: OffhostLifecycle;
|
||||
}
|
||||
|
||||
export interface BackupsList {
|
||||
|
||||
Reference in new issue
Block a user