Compare commits

..
Author SHA1 Message Date
Thomas 4b5774ec74 Merge branch 'dev' into pr-container-images 2026-07-14 17:24:32 +02:00
ajnart 8d450c69d2 fix(ci): fork-safe cleanup, manual triggers, draft PR skipping
- cleanup: pull_request -> pull_request_target so fork PRs trigger cleanup
- cleanup: add workflow_dispatch for manual stale image deletion
- publish: skip fork PRs (no packages:write) and draft PRs
- publish: add ready_for_review trigger
- publish: add workflow_dispatch for manual fork PR builds
- publish: unified tag derivation step
2026-07-11 15:41:06 +02:00
ajnart 8514df985b fix(ci): use separate homarr-test package with pr-<N> tag 2026-07-11 13:21:20 +02:00
ajnart 28d7a75b63 fix(ci): use homarr-test-pr- tag prefix instead of pr- 2026-07-11 12:19:27 +02:00
ajnart 97a661d04f fix(ci): apply CodeRabbit review fixes
- Rename tag prefix from omar-test-pr- to pr-
- Add contents: read permission to publish workflow
- Disable persist-credentials on checkout step
2026-07-11 12:17:47 +02:00
ajnart 3a1bd4e969 style: auto-fix lint and format 2026-07-11 10:03:45 +00:00
ajnart d8f831093e feat(ci): add PR Docker preview image publish and cleanup workflows 2026-07-11 12:01:56 +02:00
11 changed files with 747 additions and 472 deletions

No files matched your search

@@ -0,0 +1,64 @@
name: "[PR] Cleanup Docker preview image"
on:
pull_request_target:
types: [closed]
branches:
- dev
workflow_dispatch:
inputs:
pr-number:
description: "PR number whose preview image to delete"
required: true
type: number
permissions:
packages: write
env:
REGISTRY: ghcr.io
IMAGE_TAG: pr-${{ github.event.inputs.pr-number || github.event.number }}
jobs:
delete-image:
name: Delete PR preview image from GHCR
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Delete package version
uses: actions/github-script@v8
with:
script: |
const packageName = 'homarr-test';
const org = context.repo.owner;
const tag = process.env.IMAGE_TAG;
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{
package_type: 'container',
package_name: packageName,
org,
per_page: 100,
}
);
const target = versions.find(v =>
v.metadata?.container?.tags?.includes(tag)
);
if (!target) {
console.log(`No package version found with tag "${tag}". Nothing to delete.`);
return;
}
console.log(`Deleting package version ${target.id} (tag: ${tag})`);
await github.rest.packages.deletePackageVersionForOrg({
package_type: 'container',
package_name: packageName,
org,
package_version_id: target.id,
});
console.log(`Successfully deleted package version ${target.id}`);
@@ -0,0 +1,89 @@
name: "[PR] Publish Docker preview image"
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches:
- dev
workflow_dispatch:
inputs:
pr-number:
description: "PR number (uses head ref of that PR)"
required: true
type: number
ref:
description: "Git ref to build (branch, tag, or SHA)"
required: false
type: string
default: ""
permissions:
contents: read
packages: write
env:
SKIP_ENV_VALIDATION: true
REGISTRY: ghcr.io
GHCR_REPO: ghcr.io/homarr-labs/homarr-test
TURBO_TELEMETRY_DISABLED: 1
concurrency:
group: pr-docker-${{ github.event.inputs.pr-number || github.event.number }}
cancel-in-progress: true
jobs:
build-and-push:
name: Build and push PR preview image
runs-on: ubuntu-latest
timeout-minutes: 25
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request' &&
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository
)
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref || '' }}
- name: Determine tag
id: tag
run: |
PR_NUMBER="${{ github.event.inputs.pr-number || github.event.number }}"
echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "pr-number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: "${{ env.GHCR_REPO }}"
tags: |
type=raw,value=${{ steps.tag.outputs.tag }}
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
@@ -1,17 +1,9 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -19,12 +11,10 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -242,14 +232,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group justify="space-between" wrap="nowrap">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKey={widgetQueryKey}
isFetching={isWidgetFetching}
t={t}
/>
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
</Text>
</Group>
</Menu.Item>
<Menu.Item
@@ -286,65 +273,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
};
interface WidgetQueryStatusProps {
const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient;
queryKey: QueryKey;
isFetching: boolean;
t: TranslationFunction;
}
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({ queryKey });
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
const ageLabel = useTimeAgo(new Date(latest));
if (isFetching) return <Loader size={12} />;
if (isFetching) {
return (
<Group gap={4} wrap="nowrap">
<Loader size={12} />
<Text size="xs" c="dimmed">
{t("item.menu.status.loading")}
</Text>
</Group>
);
}
const timestamps = queryClient
.getQueryCache()
.findAll({ queryKey })
.map((q) => q.state.dataUpdatedAt)
.filter(Boolean);
if (timestamps.length === 0) return null;
if (queries.length === 0) {
return (
<Text size="xs" c="dimmed">
{t("item.menu.status.idle")}
</Text>
);
}
const hasError = queries.some((q) => q.state.status === "error");
if (hasError) {
const errorQuery = queries.find((q) => q.state.status === "error");
const errorMessage =
errorQuery?.state.error instanceof Error
? errorQuery.state.error.message
: String(errorQuery?.state.error ?? "Unknown error");
return (
<Tooltip label={errorMessage} multiline position="left" w={250}>
<Group gap={4} wrap="nowrap">
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red.6">
{t("item.menu.status.error")} · {ageLabel}
</Text>
</Group>
</Tooltip>
);
}
return (
<Group gap={4} wrap="nowrap">
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("item.menu.status.success")} · {ageLabel}
</Text>
</Group>
);
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
};
@@ -153,9 +153,9 @@ export class OverseerrIntegration
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return [];
else return Promise.all([]);
const settled = await Promise.allSettled(
return await Promise.all(
requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
@@ -184,10 +184,6 @@ export class OverseerrIntegration
};
}),
);
return settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
}
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -305,10 +301,6 @@ export class OverseerrIntegration
},
});
if (!response.ok) {
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
}
if (type === "tv") {
const series = (await response.json()) as TvInformation;
return {
@@ -13,7 +13,6 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
}
export const createIntegrationRequestHandler = <
@@ -33,7 +32,6 @@ export const createIntegrationRequestHandler = <
return options.requestAsync(integration, input.options);
},
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
});
return {
@@ -1,7 +1,6 @@
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
@@ -36,6 +35,7 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached) cache.delete(key);
const existing = inflight.get(key);
if (existing) return existing;
@@ -55,9 +55,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
})
.catch((err) => {
inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
return cached;
}
throw err;
});
@@ -13,6 +13,4 @@ export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getRequestsAsync();
},
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
});
+1 -7
View File
@@ -1598,16 +1598,10 @@
},
"menu": {
"label": {
"settings": "",
"settings": "Settings",
"refresh": "",
"options": "",
"actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
}
},
"create": {
-6
View File
@@ -1622,12 +1622,6 @@
"refresh": "Refresh",
"options": "Options",
"actions": "Actions"
},
"status": {
"success": "OK",
"error": "Error",
"loading": "Loading",
"idle": "Idle"
}
},
"create": {
+556 -363
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -18,7 +18,7 @@ catalog:
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0
"@immich/sdk": ^3.0.2
"@immich/sdk": ^3.0.1
"@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0
"@modelcontextprotocol/sdk": 1.29.0
@@ -139,7 +139,7 @@ catalog:
jsonpath-plus: ^10.4.0
ldapts: 8.1.8
lodash.clonedeep: ^4.5.0
lucide-react: ^1.24.0
lucide-react: ^1.23.0
"mantine-contextmenu": ^9.3.1
mantine-datatable: ^9.3.1
mantine-react-table: npm:mantine-react-table-open@9.0.3
@@ -177,7 +177,7 @@ catalog:
tsdav: ^2.3.0
tsx: 4.20.4
turbo: ^2.10.4
typescript: "npm:@typescript/typescript6@^6.0.2"
typescript: ^7.0.2
vite: ^8.1.4
undici: 7.28.0
use-deep-compare-effect: ^1.8.1