upload ai slop

This commit is contained in:
2026-02-25 22:49:38 +02:00
parent 49ebbc34fa
commit 5bd89c3291
119 changed files with 3513 additions and 129 deletions

View File

@@ -12,3 +12,6 @@ __pycache__/
*.pyc *.pyc
*.pyd *.pyd
*.pyo *.pyo
node_modules/
frontend/

6
.gitignore vendored
View File

@@ -99,3 +99,9 @@ status_cache.json
*.db *.db
docs/html/ docs/html/
# Frontend
node_modules/
package-lock.json
package/share/ahriman/templates/static/index.js
package/share/ahriman/templates/static/index.css

View File

@@ -188,6 +188,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
* ``port`` - port to bind, integer, optional. * ``port`` - port to bind, integer, optional.
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``. * ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
* ``static_path`` - path to directory with static files, string, required. * ``static_path`` - path to directory with static files, string, required.
* ``template`` - Jinja2 template name for the index page, string, optional, default ``build-status.jinja2``.
* ``templates`` - path to templates directories, space separated list of paths, required. * ``templates`` - path to templates directories, space separated list of paths, required.
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization. * ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
* ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration. * ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration.

49
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,49 @@
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"@stylistic": stylistic,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"curly": "error",
"@stylistic/brace-style": ["error", "1tbs"],
// stylistic
"@stylistic/indent": ["error", 4],
"@stylistic/quotes": ["error", "double"],
"@stylistic/semi": ["error", "always"],
"@stylistic/comma-dangle": ["error", "always-multiline"],
"@stylistic/object-curly-spacing": ["error", "always"],
"@stylistic/array-bracket-spacing": ["error", "never"],
"@stylistic/arrow-parens": ["error", "always"],
"@stylistic/eol-last": ["error", "always"],
"@stylistic/no-trailing-spaces": "error",
"@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
"@stylistic/jsx-quotes": ["error", "prefer-double"],
// typescript
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
"@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
);

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ahriman</title>
<link rel="icon" href="/static/favicon.ico" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
frontend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "ahriman-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src/",
"lint:fix": "eslint --fix src/",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.0",
"@mui/material": "^5.15.0",
"@mui/x-data-grid": "^7.0.0",
"@tanstack/react-query": "^5.0.0",
"chart.js": "^4.5.0",
"highlight.js": "^11.11.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin": "^5.9.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"typescript": "^5.3.0",
"typescript-eslint": "^8.56.1",
"vite": "^5.0.0",
"vite-tsconfig-paths": "^6.1.1"
}
}

35
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,35 @@
import type React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import Theme from "theme/Theme";
import { AuthProvider } from "contexts/AuthProvider";
import { RepositoryProvider } from "contexts/RepositoryProvider";
import { NotificationProvider } from "contexts/NotificationProvider";
import AppLayout from "components/layout/AppLayout";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
export default function App(): React.JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={Theme}>
<CssBaseline />
<AuthProvider>
<RepositoryProvider>
<NotificationProvider>
<AppLayout />
</NotificationProvider>
</RepositoryProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,30 @@
import type { RepositoryId } from "api/types/RepositoryId";
function repoKey(repo: RepositoryId): string {
return `${repo.architecture}-${repo.repository}`;
}
export const QueryKeys = {
info: ["info"] as const,
packages: (repo: RepositoryId) => ["packages", repoKey(repo)] as const,
package: (base: string, repo: RepositoryId) => ["packages", repoKey(repo), base] as const,
status: (repo: RepositoryId) => ["status", repoKey(repo)] as const,
logs: (base: string, repo: RepositoryId) => ["logs", repoKey(repo), base] as const,
logsVersion: (base: string, repo: RepositoryId, version: string, processId: string) =>
["logs", repoKey(repo), base, version, processId] as const,
changes: (base: string, repo: RepositoryId) => ["changes", repoKey(repo), base] as const,
dependencies: (base: string, repo: RepositoryId) => ["dependencies", repoKey(repo), base] as const,
patches: (base: string) => ["patches", base] as const,
events: (repo: RepositoryId, objectId?: string) => ["events", repoKey(repo), objectId] as const,
search: (query: string) => ["search", query] as const,
pgpKey: (key: string, server: string) => ["pgp", key, server] as const,
};

View File

@@ -0,0 +1,216 @@
import type { AURPackage } from "api/types/AURPackage";
import type { Changes } from "api/types/Changes";
import type { Dependencies } from "api/types/Dependencies";
import type { Event } from "api/types/Event";
import type { InfoResponse } from "api/types/InfoResponse";
import type { InternalStatus } from "api/types/InternalStatus";
import type { LogRecord } from "api/types/LogRecord";
import type { LoginRequest } from "api/types/LoginRequest";
import type { PackageActionRequest } from "api/types/PackageActionRequest";
import type { PackageStatus } from "api/types/PackageStatus";
import type { Patch } from "api/types/Patch";
import type { PGPKey } from "api/types/PGPKey";
import type { PGPKeyRequest } from "api/types/PGPKeyRequest";
import type { RepositoryId } from "api/types/RepositoryId";
import { ApiError } from "api/client/ApiError";
import type { RequestOptions } from "api/client/RequestOptions";
export class AhrimanClient {
private static repoQuery(repo: RepositoryId): Record<string, string> {
return { architecture: repo.architecture, repository: repo.repository };
}
private async request<T>(url: string, options: RequestOptions = {}): Promise<T> {
const { method, query, json } = options;
let fullUrl = url;
if (query) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
params.set(key, String(value));
}
}
fullUrl += `?${params.toString()}`;
}
const requestInit: RequestInit = {
method: method || (json ? "POST" : "GET"),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
};
if (json !== undefined) {
requestInit.body = JSON.stringify(json);
}
const response = await fetch(fullUrl, requestInit);
if (!response.ok) {
const body = await response.text();
throw new ApiError(response.status, response.statusText, body);
}
const contentType = response.headers.get("Content-Type") ?? "";
if (contentType.includes("application/json")) {
return await response.json() as T;
}
return await response.text() as T;
}
// Info
async fetchInfo(): Promise<InfoResponse> {
return this.request<InfoResponse>("/api/v2/info");
}
// Packages
async fetchPackages(repo: RepositoryId): Promise<PackageStatus[]> {
return this.request<PackageStatus[]>("/api/v1/packages", { query: AhrimanClient.repoQuery(repo) });
}
async fetchPackage(base: string, repo: RepositoryId): Promise<PackageStatus[]> {
return this.request<PackageStatus[]>(`/api/v1/packages/${encodeURIComponent(base)}`, {
query: AhrimanClient.repoQuery(repo),
});
}
// Status
async fetchStatus(repo: RepositoryId): Promise<InternalStatus> {
return this.request<InternalStatus>("/api/v1/status", { query: AhrimanClient.repoQuery(repo) });
}
// Logs
async fetchLogs(
base: string,
repo: RepositoryId,
version?: string,
processId?: string,
head?: boolean,
): Promise<LogRecord[]> {
const query: Record<string, string | boolean> = AhrimanClient.repoQuery(repo);
if (version) {
query.version = version;
}
if (processId) {
query.process_id = processId;
}
if (head) {
query.head = true;
}
return this.request<LogRecord[]>(`/api/v2/packages/${encodeURIComponent(base)}/logs`, { query });
}
// Changes
async fetchChanges(base: string, repo: RepositoryId): Promise<Changes> {
return this.request<Changes>(`/api/v1/packages/${encodeURIComponent(base)}/changes`, {
query: AhrimanClient.repoQuery(repo),
});
}
// Dependencies
async fetchDependencies(base: string, repo: RepositoryId): Promise<Dependencies> {
return this.request<Dependencies>(`/api/v1/packages/${encodeURIComponent(base)}/dependencies`, {
query: AhrimanClient.repoQuery(repo),
});
}
// Patches
async fetchPatches(base: string): Promise<Patch[]> {
return this.request<Patch[]>(`/api/v1/packages/${encodeURIComponent(base)}/patches`);
}
async deletePatch(base: string, key: string): Promise<void> {
return this.request(`/api/v1/packages/${encodeURIComponent(base)}/patches/${encodeURIComponent(key)}`, {
method: "DELETE",
});
}
// Events
async fetchEvents(repo: RepositoryId, objectId?: string, limit?: number): Promise<Event[]> {
const query: Record<string, string | number> = AhrimanClient.repoQuery(repo);
if (objectId) {
query.object_id = objectId;
}
if (limit) {
query.limit = limit;
}
return this.request<Event[]>("/api/v1/events", { query });
}
// Service actions
async addPackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
return this.request("/api/v1/service/add", { method: "POST", query: AhrimanClient.repoQuery(repo), json: data });
}
async removePackages(repo: RepositoryId, packages: string[]): Promise<void> {
return this.request("/api/v1/service/remove", {
method: "POST",
query: AhrimanClient.repoQuery(repo),
json: { packages },
});
}
async updatePackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
return this.request("/api/v1/service/update", {
method: "POST",
query: AhrimanClient.repoQuery(repo),
json: data,
});
}
async rebuildPackages(repo: RepositoryId, packages: string[]): Promise<void> {
return this.request("/api/v1/service/rebuild", {
method: "POST",
query: AhrimanClient.repoQuery(repo),
json: { packages },
});
}
async requestPackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
return this.request("/api/v1/service/request", {
method: "POST",
query: AhrimanClient.repoQuery(repo),
json: data,
});
}
// Search
async searchPackages(query: string): Promise<AURPackage[]> {
return this.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
}
// PGP
async fetchPGPKey(key: string, server: string): Promise<PGPKey> {
return this.request<PGPKey>("/api/v1/service/pgp", { query: { key, server } });
}
async importPGPKey(data: PGPKeyRequest): Promise<void> {
return this.request("/api/v1/service/pgp", { method: "POST", json: data });
}
// Auth
async login(data: LoginRequest): Promise<void> {
return this.request("/api/v1/login", { method: "POST", json: data });
}
async logout(): Promise<void> {
return this.request("/api/v1/logout", { method: "POST" });
}
}
export const Client = new AhrimanClient();

View File

@@ -0,0 +1,21 @@
export class ApiError extends Error {
status: number;
statusText: string;
body: string;
constructor(status: number, statusText: string, body: string) {
super(`${status} ${statusText}`);
this.status = status;
this.statusText = statusText;
this.body = body;
}
get detail(): string {
try {
const parsed = JSON.parse(this.body) as Record<string, string>;
return parsed.error ?? (this.body || this.message);
} catch {
return this.body || this.message;
}
}
}

View File

@@ -0,0 +1,5 @@
export interface RequestOptions {
method?: string;
query?: Record<string, string | number | boolean>;
json?: unknown;
}

View File

@@ -0,0 +1,4 @@
export interface AURPackage {
package: string;
description: string;
}

View File

@@ -0,0 +1,5 @@
export interface AuthInfo {
control: string;
enabled: boolean;
username?: string;
}

View File

@@ -0,0 +1,5 @@
export interface AutoRefreshInterval {
interval: number;
is_active: boolean;
text: string;
}

View File

@@ -0,0 +1 @@
export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success";

View File

@@ -0,0 +1,4 @@
export interface Changes {
changes?: string;
last_commit_sha?: string;
}

View File

@@ -0,0 +1,8 @@
export interface Counters {
building: number;
failed: number;
pending: number;
success: number;
total: number;
unknown: number;
}

View File

@@ -0,0 +1,3 @@
export interface Dependencies {
paths: Record<string, string[]>;
}

View File

@@ -0,0 +1,7 @@
export interface Event {
created: number;
data?: Record<string, unknown>;
event: string;
message?: string;
object_id: string;
}

View File

@@ -0,0 +1,12 @@
import type { AuthInfo } from "api/types/AuthInfo";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { RepositoryId } from "api/types/RepositoryId";
export interface InfoResponse {
auth: AuthInfo;
repositories: RepositoryId[];
version: string;
autorefresh_intervals: AutoRefreshInterval[];
docs_enabled: boolean;
index_url?: string;
}

View File

@@ -0,0 +1,12 @@
import type { Counters } from "api/types/Counters";
import type { RepositoryStats } from "api/types/RepositoryStats";
import type { Status } from "api/types/Status";
export interface InternalStatus {
architecture: string;
repository: string;
packages: Counters;
stats: RepositoryStats;
status: Status;
version: string;
}

View File

@@ -0,0 +1,6 @@
export interface LogRecord {
created: number;
message: string;
process_id: string;
version: string;
}

View File

@@ -0,0 +1,4 @@
export interface LoginRequest {
username: string;
password: string;
}

View File

@@ -0,0 +1,3 @@
export interface PGPKey {
key: string;
}

View File

@@ -0,0 +1,4 @@
export interface PGPKeyRequest {
key: string;
server: string;
}

View File

@@ -0,0 +1,10 @@
import type { PackageProperties } from "api/types/PackageProperties";
import type { Remote } from "api/types/Remote";
export interface Package {
base: string;
packager?: string;
packages: Record<string, PackageProperties>;
remote: Remote;
version: string;
}

View File

@@ -0,0 +1,10 @@
import type { Patch } from "api/types/Patch";
export interface PackageActionRequest {
packages: string[];
patches?: Patch[];
refresh?: boolean;
aur?: boolean;
local?: boolean;
manual?: boolean;
}

View File

@@ -0,0 +1,16 @@
export interface PackageProperties {
architecture?: string;
archive_size?: number;
build_date?: number;
check_depends?: string[];
depends?: string[];
description?: string;
filename?: string;
groups?: string[];
installed_size?: number;
licenses?: string[];
make_depends?: string[];
opt_depends?: string[];
provides?: string[];
url?: string;
}

View File

@@ -0,0 +1,15 @@
import type { BuildStatus } from "api/types/BuildStatus";
export interface PackageRow {
id: string;
base: string;
webUrl?: string;
version: string;
packages: string[];
groups: string[];
licenses: string[];
packager: string;
timestamp: string;
timestampValue: number;
status: BuildStatus;
}

View File

@@ -0,0 +1,9 @@
import type { Package } from "api/types/Package";
import type { RepositoryId } from "api/types/RepositoryId";
import type { Status } from "api/types/Status";
export interface PackageStatus {
package: Package;
status: Status;
repository: RepositoryId;
}

View File

@@ -0,0 +1,4 @@
export interface Patch {
key: string;
value: string;
}

View File

@@ -0,0 +1,7 @@
export interface Remote {
branch?: string;
git_url?: string;
path?: string;
source: string;
web_url?: string;
}

View File

@@ -0,0 +1,4 @@
export interface RepositoryId {
architecture: string;
repository: string;
}

View File

@@ -0,0 +1,6 @@
export interface RepositoryStats {
archive_size?: number;
bases?: number;
installed_size?: number;
packages?: number;
}

View File

@@ -0,0 +1,6 @@
import type { BuildStatus } from "api/types/BuildStatus";
export interface Status {
status: BuildStatus;
timestamp: number;
}

View File

@@ -0,0 +1,33 @@
import type React from "react";
import { Line } from "react-chartjs-2";
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from "chart.js";
import type { Event } from "api/types/Event";
import { formatTimestamp } from "components/common/formatTimestamp";
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend);
interface EventDurationLineChartProps {
events: Event[];
}
export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element | null {
const updateEvents = events.filter((e) => e.event === "package-updated" && e.data?.took);
if (updateEvents.length === 0) {
return null;
}
const data = {
labels: updateEvents.map((e) => formatTimestamp(e.created)),
datasets: [
{
label: "update duration, s",
data: updateEvents.map((e) => (e.data as Record<string, number>).took),
cubicInterpolationMode: "monotone" as const,
tension: 0.4,
},
],
};
return <Line data={data} options={{ responsive: true }} />;
}

View File

@@ -0,0 +1,39 @@
import type React from "react";
import { Bar } from "react-chartjs-2";
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js";
import type { RepositoryStats } from "api/types/RepositoryStats";
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend);
interface PackageCountBarChartProps {
stats: RepositoryStats;
}
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
const data = {
labels: ["packages"],
datasets: [
{
label: "archives",
data: [stats.packages ?? 0],
},
{
label: "bases",
data: [stats.bases ?? 0],
},
],
};
return (
<Bar
data={data}
options={{
maintainAspectRatio: false,
responsive: true,
scales: {
x: { stacked: true },
},
}}
/>
);
}

View File

@@ -0,0 +1,27 @@
import type React from "react";
import { Pie } from "react-chartjs-2";
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
import type { Counters } from "api/types/Counters";
import { StatusColors } from "theme/status/StatusColors";
ChartJS.register(ArcElement, Tooltip, Legend);
interface StatusPieChartProps {
counters: Counters;
}
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
const labels = ["unknown", "pending", "building", "failed", "success"] as const;
const data = {
labels: labels.map((l) => l),
datasets: [
{
label: "packages in status",
data: labels.map((label) => counters[label]),
backgroundColor: labels.map((label) => StatusColors[label]),
},
],
};
return <Pie data={data} options={{ responsive: true }} />;
}

View File

@@ -0,0 +1,75 @@
import React, { useState } from "react";
import { IconButton, Menu, MenuItem, Tooltip, ListItemIcon, ListItemText } from "@mui/material";
import TimerIcon from "@mui/icons-material/Timer";
import TimerOffIcon from "@mui/icons-material/TimerOff";
import CheckIcon from "@mui/icons-material/Check";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
interface AutoRefreshControlProps {
intervals: AutoRefreshInterval[];
enabled: boolean;
currentInterval: number;
onToggle: (enabled: boolean) => void;
onIntervalChange: (interval: number) => void;
}
export default function AutoRefreshControl({
intervals,
enabled,
currentInterval,
onToggle,
onIntervalChange,
}: AutoRefreshControlProps): React.JSX.Element | null {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
if (intervals.length === 0) {
return null;
}
return (
<>
<Tooltip title="Auto-refresh">
<IconButton
size="small"
onClick={(e) => setAnchorEl(e.currentTarget)}
color={enabled ? "primary" : "default"}
>
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
<MenuItem
selected={!enabled}
onClick={() => {
onToggle(false);
setAnchorEl(null);
}}
>
<ListItemIcon>
{!enabled && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>Off</ListItemText>
</MenuItem>
{intervals.map((iv) => (
<MenuItem
key={iv.interval}
selected={enabled && iv.interval === currentInterval}
onClick={() => {
onIntervalChange(iv.interval);
setAnchorEl(null);
}}
>
<ListItemIcon>
{enabled && iv.interval === currentInterval && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>{iv.text}</ListItemText>
</MenuItem>
))}
</Menu>
</>
);
}

View File

@@ -0,0 +1,26 @@
import React, { useState } from "react";
import { IconButton, Tooltip } from "@mui/material";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import CheckIcon from "@mui/icons-material/Check";
interface CopyButtonProps {
getText: () => string;
}
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false);
const handleCopy = async (): Promise<void> => {
await navigator.clipboard.writeText(getText());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Tooltip title={copied ? "Copied!" : "Copy"}>
<IconButton size="small" onClick={() => void handleCopy()}>
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</Tooltip>
);
}

View File

@@ -0,0 +1,5 @@
export function formatTimestamp(unixSeconds: number): string {
const d = new Date(unixSeconds * 1000);
const pad = (n: number): string => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}

View File

@@ -0,0 +1,88 @@
import type React from "react";
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Typography, Box } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useQuery } from "@tanstack/react-query";
import StatusPieChart from "components/charts/StatusPieChart";
import PackageCountBarChart from "components/charts/PackageCountBarChart";
import { Client } from "api/client/AhrimanClient";
import { QueryKeys } from "api/QueryKeys";
import { useRepository } from "hooks/useRepository";
import { StatusHeaderStyles } from "theme/status/StatusColors";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { InternalStatus } from "api/types/InternalStatus";
interface DashboardDialogProps {
open: boolean;
onClose: () => void;
}
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
const { current } = useRepository();
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: () => Client.fetchStatus(current!),
enabled: !!current && open,
});
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle sx={headerStyle}>System health</DialogTitle>
<DialogContent>
{status && (
<>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={6} md={3}>
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2">{status.repository}</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2">{status.architecture}</Typography>
</Grid>
</Grid>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={6} md={3}>
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2">{status.status.status}</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2">{formatTimestamp(status.status.timestamp)}</Typography>
</Grid>
</Grid>
{status.packages.total > 0 && (
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={12} md={6}>
<Box sx={{ maxHeight: 300 }}>
<PackageCountBarChart stats={status.stats} />
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Box sx={{ maxHeight: 300 }}>
<StatusPieChart counters={status.packages} />
</Box>
</Grid>
</Grid>
)}
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="contained" startIcon={<CloseIcon />}>close</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,106 @@
import React, { useState } from "react";
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, Box,
} from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useNotification } from "hooks/useNotification";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import CopyButton from "components/common/CopyButton";
interface KeyImportDialogProps {
open: boolean;
onClose: () => void;
}
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
const { showSuccess, showError } = useNotification();
const [fingerprint, setFingerprint] = useState("");
const [server, setServer] = useState("keyserver.ubuntu.com");
const [keyBody, setKeyBody] = useState("");
const handleFetch = async (): Promise<void> => {
if (!fingerprint || !server) {
return;
}
try {
const result = await Client.fetchPGPKey(fingerprint, server);
setKeyBody(result.key);
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Could not fetch key: ${detail}`);
}
};
const handleImport = async (): Promise<void> => {
if (!fingerprint || !server) {
return;
}
try {
await Client.importPGPKey({ key: fingerprint, server });
onClose();
showSuccess("Success", `Key ${fingerprint} has been imported`);
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
}
};
const handleClose = (): void => {
setFingerprint("");
setServer("keyserver.ubuntu.com");
setKeyBody("");
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogTitle>Import key from PGP server</DialogTitle>
<DialogContent>
<TextField
label="fingerprint"
placeholder="PGP key fingerprint"
fullWidth
margin="normal"
value={fingerprint}
onChange={(e) => setFingerprint(e.target.value)}
/>
<TextField
label="key server"
placeholder="PGP key server"
fullWidth
margin="normal"
value={server}
onChange={(e) => setServer(e.target.value)}
/>
{keyBody && (
<Box sx={{ position: "relative", mt: 2 }}>
<Box
component="pre"
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
maxHeight: 300,
fontSize: "0.85rem",
}}
>
<code>{keyBody}</code>
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => keyBody} />
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,91 @@
import React, { useState } from "react";
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
InputAdornment, IconButton,
} from "@mui/material";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import PersonIcon from "@mui/icons-material/Person";
import { useAuth } from "hooks/useAuth";
import { useNotification } from "hooks/useNotification";
import { ApiError } from "api/client/ApiError";
interface LoginDialogProps {
open: boolean;
onClose: () => void;
}
export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const { login } = useAuth();
const { showSuccess, showError } = useNotification();
const handleSubmit = async (): Promise<void> => {
if (!username || !password) {
return;
}
try {
await login(username, password);
onClose();
showSuccess("Logged in", `Successfully logged in as ${username}`);
window.location.href = "/";
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
if (username === "admin" && password === "admin") {
showError("Login error", "You've entered a password for user \"root\", did you make a typo in username?");
} else {
showError("Login error", `Could not login as ${username}: ${detail}`);
}
}
};
const handleClose = (): void => {
setUsername("");
setPassword("");
setShowPassword(false);
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
<DialogTitle>Login</DialogTitle>
<DialogContent>
<TextField
label="username"
fullWidth
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
<TextField
label="password"
fullWidth
margin="normal"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleSubmit();
}
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end" size="small">
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</InputAdornment>
),
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,194 @@
import React, { useState, useCallback } from "react";
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, Autocomplete, Box, IconButton, FormControlLabel, Checkbox, Select, MenuItem, InputLabel, FormControl,
} from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import { useQuery } from "@tanstack/react-query";
import { useRepository } from "hooks/useRepository";
import { useNotification } from "hooks/useNotification";
import { useDebounce } from "hooks/useDebounce";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "api/QueryKeys";
import type { AURPackage } from "api/types/AURPackage";
import type { RepositoryId } from "api/types/RepositoryId";
interface EnvVar {
key: string;
value: string;
}
interface PackageAddDialogProps {
open: boolean;
onClose: () => void;
}
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
const { repositories, current } = useRepository();
const { showSuccess, showError } = useNotification();
const [packageName, setPackageName] = useState("");
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [refresh, setRefresh] = useState(true);
const [envVars, setEnvVars] = useState<EnvVar[]>([]);
const debouncedSearch = useDebounce(packageName, 500);
const { data: searchResults = [] } = useQuery<AURPackage[]>({
queryKey: QueryKeys.search(debouncedSearch),
queryFn: () => Client.searchPackages(debouncedSearch),
enabled: debouncedSearch.length >= 3,
});
const getSelectedRepo = useCallback((): RepositoryId => {
if (selectedRepo) {
const repo = repositories.find(
(r) => `${r.architecture}-${r.repository}` === selectedRepo,
);
if (repo) {
return repo;
}
}
return current!;
}, [selectedRepo, repositories, current]);
const handleAdd = async (): Promise<void> => {
if (!packageName) {
return;
}
const repo = getSelectedRepo();
try {
const patches = envVars.filter((v) => v.key);
await Client.addPackages(repo, {
packages: [packageName],
patches: patches.length > 0 ? patches : undefined,
refresh,
});
onClose();
showSuccess("Success", `Packages ${packageName} have been added`);
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Package addition failed: ${detail}`);
}
};
const handleRequest = async (): Promise<void> => {
if (!packageName) {
return;
}
const repo = getSelectedRepo();
try {
const patches = envVars.filter((v) => v.key);
await Client.requestPackages(repo, {
packages: [packageName],
patches: patches.length > 0 ? patches : undefined,
});
onClose();
showSuccess("Success", `Packages ${packageName} have been requested`);
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Package request failed: ${detail}`);
}
};
const handleClose = (): void => {
setPackageName("");
setSelectedRepo("");
setRefresh(true);
setEnvVars([]);
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>Add new packages</DialogTitle>
<DialogContent>
<FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel>
<Select
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
label="repository"
onChange={(e) => setSelectedRepo(e.target.value)}
>
{repositories.map((r) => (
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
{r.repository} ({r.architecture})
</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
freeSolo
options={searchResults.map((p) => p.package)}
inputValue={packageName}
onInputChange={(_, value) => setPackageName(value)}
renderOption={(props, option) => {
const pkg = searchResults.find((p) => p.package === option);
return (
<li {...props} key={option}>
{option}{pkg ? ` (${pkg.description})` : ""}
</li>
);
}}
renderInput={(params) => (
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
)}
/>
<FormControlLabel
control={<Checkbox checked={refresh} onChange={(_, checked) => setRefresh(checked)} />}
label="update pacman databases"
/>
<Button
fullWidth
variant="outlined"
startIcon={<AddIcon />}
onClick={() => setEnvVars([...envVars, { key: "", value: "" }])}
sx={{ mt: 1 }}
>
add environment variable
</Button>
{envVars.map((env, index) => (
<Box key={index} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
<TextField
size="small"
placeholder="name"
value={env.key}
onChange={(e) => {
const updated = [...envVars];
updated[index] = { ...updated[index], key: e.target.value };
setEnvVars(updated);
}}
sx={{ flex: 1 }}
/>
<Box>=</Box>
<TextField
size="small"
placeholder="value"
value={env.value}
onChange={(e) => {
const updated = [...envVars];
updated[index] = { ...updated[index], value: e.target.value };
setEnvVars(updated);
}}
sx={{ flex: 1 }}
/>
<IconButton size="small" color="error" onClick={() => setEnvVars(envVars.filter((_, i) => i !== index))}>
<DeleteIcon />
</IconButton>
</Box>
))}
</DialogContent>
<DialogActions>
<Button onClick={() => void handleAdd()} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
<Button onClick={() => void handleRequest()} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,299 @@
import React, { useState } from "react";
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
Grid, Typography, Link, Box, Tab, Tabs, IconButton, Chip, FormControlLabel, Checkbox,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import DeleteIcon from "@mui/icons-material/Delete";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import BuildLogsTab from "components/package/BuildLogsTab";
import ChangesTab from "components/package/ChangesTab";
import EventsTab from "components/package/EventsTab";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import { useRepository } from "hooks/useRepository";
import { useAuth } from "hooks/useAuth";
import { useNotification } from "hooks/useNotification";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "api/QueryKeys";
import { StatusHeaderStyles } from "theme/status/StatusColors";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { Dependencies } from "api/types/Dependencies";
import type { PackageProperties } from "api/types/PackageProperties";
import type { PackageStatus } from "api/types/PackageStatus";
import type { Patch } from "api/types/Patch";
import type { RepositoryId } from "api/types/RepositoryId";
interface PackageInfoDialogProps {
packageBase: string | null;
open: boolean;
onClose: () => void;
autorefreshIntervals: AutoRefreshInterval[];
}
function listToString(items: string[]): React.ReactNode {
const unique = [...new Set(items)].sort();
return unique.map((item, i) => (
<React.Fragment key={item}>
{item}
{i < unique.length - 1 && <br />}
</React.Fragment>
));
}
export default function PackageInfoDialog({ packageBase, open, onClose, autorefreshIntervals }: PackageInfoDialogProps): React.JSX.Element {
const { current } = useRepository();
const { enabled: authEnabled, username } = useAuth();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const hasAuth = !authEnabled || username !== null;
const [tabIndex, setTabIndex] = useState(0);
const [refreshDb, setRefreshDb] = useState(true);
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval);
const repo = current as RepositoryId;
const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: packageBase && repo ? QueryKeys.package(packageBase, repo) : ["package-none"],
queryFn: () => Client.fetchPackage(packageBase!, repo),
enabled: !!packageBase && !!repo && open,
refetchInterval: autoRefresh.refetchInterval,
});
const { data: dependencies } = useQuery<Dependencies>({
queryKey: packageBase && repo ? QueryKeys.dependencies(packageBase, repo) : ["deps-none"],
queryFn: () => Client.fetchDependencies(packageBase!, repo),
enabled: !!packageBase && !!repo && open,
});
const { data: patches = [] } = useQuery<Patch[]>({
queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches-none"],
queryFn: () => Client.fetchPatches(packageBase!),
enabled: !!packageBase && open,
});
const description: PackageStatus | undefined = packageData?.[0];
const pkg = description?.package;
const status = description?.status;
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
// Flatten depends from all sub-packages
const allDepends: string[] = pkg
? Object.values(pkg.packages).flatMap((p: PackageProperties) => {
const pkgNames = Object.keys(pkg.packages);
const deps = (p.depends ?? []).filter((d: string) => !pkgNames.includes(d));
const makeDeps = (p.make_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (make)`);
const optDeps = (p.opt_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (optional)`);
return [...deps, ...makeDeps, ...optDeps];
})
: [];
const implicitDepends: string[] = dependencies
? Object.values(dependencies.paths).flat()
: [];
const groups: string[] = pkg
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.groups ?? [])
: [];
const licenses: string[] = pkg
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.licenses ?? [])
: [];
const upstreamUrls: string[] = pkg
? [...new Set(Object.values(pkg.packages).map((p: PackageProperties) => p.url).filter((u): u is string => !!u))].sort()
: [];
const aurUrl = pkg?.remote.web_url;
const packagesList: string[] = pkg
? Object.entries(pkg.packages).map(([name, p]) => `${name}${p.description ? ` (${p.description})` : ""}`)
: [];
const handleUpdate = async (): Promise<void> => {
if (!packageBase || !repo) {
return;
}
try {
await Client.addPackages(repo, { packages: [packageBase], refresh: refreshDb });
showSuccess("Success", `Run update for packages ${packageBase}`);
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Package update failed: ${detail}`);
}
};
const handleRemove = async (): Promise<void> => {
if (!packageBase || !repo) {
return;
}
try {
await Client.removePackages(repo, [packageBase]);
showSuccess("Success", `Packages ${packageBase} have been removed`);
onClose();
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Could not remove package: ${detail}`);
}
};
const handleDeletePatch = async (key: string): Promise<void> => {
if (!packageBase) {
return;
}
try {
await Client.deletePatch(packageBase, key);
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Could not delete variable: ${detail}`);
}
};
const handleReload = (): void => {
if (!packageBase || !repo) {
return;
}
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, repo) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.logs(packageBase, repo) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.changes(packageBase, repo) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.events(repo, packageBase) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.dependencies(packageBase, repo) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
};
const handleClose = (): void => {
setTabIndex(0);
setRefreshDb(true);
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogTitle sx={headerStyle}>
{pkg && status
? `${pkg.base} ${status.status} at ${formatTimestamp(status.timestamp)}`
: packageBase ?? ""}
</DialogTitle>
<DialogContent>
{pkg && (
<>
<Grid container spacing={1} sx={{ mt: 1 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(packagesList)}</Typography></Grid>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.version}</Typography></Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
<Grid item xs={4} md={1} />
<Grid item xs={8} md={5} />
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(groups)}</Typography></Grid>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(licenses)}</Typography></Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
<Grid item xs={8} md={5}>
{upstreamUrls.map((url) => (
<Link key={url} href={url} target="_blank" rel="noopener" underline="hover" display="block" variant="body2">
{url}
</Link>
))}
</Grid>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
<Grid item xs={8} md={5}>
{aurUrl && (
<Link href={aurUrl} target="_blank" rel="noopener" underline="hover" variant="body2">AUR link</Link>
)}
</Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(allDepends)}</Typography></Grid>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(implicitDepends)}</Typography></Grid>
</Grid>
{patches.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Environment variables</Typography>
{patches.map((patch) => (
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<Chip label={patch.key} size="small" />
<Typography variant="body2">=</Typography>
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{JSON.stringify(patch.value)}</Typography>
{hasAuth && (
<IconButton size="small" color="error" onClick={() => void handleDeletePatch(patch.key)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
))}
</Box>
)}
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs value={tabIndex} onChange={(_, v: number) => setTabIndex(v)}>
<Tab label="Build logs" />
<Tab label="Changes" />
<Tab label="Events" />
</Tabs>
</Box>
{tabIndex === 0 && packageBase && repo && (
<BuildLogsTab packageBase={packageBase} repo={repo} refetchInterval={autoRefresh.refetchInterval} />
)}
{tabIndex === 1 && packageBase && repo && (
<ChangesTab packageBase={packageBase} repo={repo} />
)}
{tabIndex === 2 && packageBase && repo && (
<EventsTab packageBase={packageBase} repo={repo} />
)}
</>
)}
</DialogContent>
<DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
{hasAuth && (
<>
<FormControlLabel
control={<Checkbox checked={refreshDb} onChange={(_, checked) => setRefreshDb(checked)} size="small" />}
label="update pacman databases"
/>
<Button onClick={() => void handleUpdate()} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
update
</Button>
<Button onClick={() => void handleRemove()} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
remove
</Button>
</>
)}
<Button onClick={handleReload} variant="outlined" color="secondary" startIcon={<RefreshIcon />} size="small">
reload
</Button>
<AutoRefreshControl
intervals={autorefreshIntervals}
enabled={autoRefresh.enabled}
currentInterval={autoRefresh.interval}
onToggle={autoRefresh.setEnabled}
onIntervalChange={autoRefresh.setInterval}
/>
<Button onClick={handleClose} variant="contained" startIcon={<CloseIcon />} size="small">
close
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,89 @@
import React, { useState } from "react";
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, Select, MenuItem, InputLabel, FormControl,
} from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { useRepository } from "hooks/useRepository";
import { useNotification } from "hooks/useNotification";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import type { RepositoryId } from "api/types/RepositoryId";
interface PackageRebuildDialogProps {
open: boolean;
onClose: () => void;
}
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
const { repositories, current } = useRepository();
const { showSuccess, showError } = useNotification();
const [dependency, setDependency] = useState("");
const [selectedRepo, setSelectedRepo] = useState<string>("");
const getSelectedRepo = (): RepositoryId => {
if (selectedRepo) {
const repo = repositories.find((r) => `${r.architecture}-${r.repository}` === selectedRepo);
if (repo) {
return repo;
}
}
return current!;
};
const handleRebuild = async (): Promise<void> => {
if (!dependency) {
return;
}
const repo = getSelectedRepo();
try {
await Client.rebuildPackages(repo, [dependency]);
onClose();
showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Repository rebuild failed: ${detail}`);
}
};
const handleClose = (): void => {
setDependency("");
setSelectedRepo("");
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>Rebuild depending packages</DialogTitle>
<DialogContent>
<FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel>
<Select
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
label="repository"
onChange={(e) => setSelectedRepo(e.target.value)}
>
{repositories.map((r) => (
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
{r.repository} ({r.architecture})
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="dependency"
placeholder="packages dependency"
fullWidth
margin="normal"
value={dependency}
onChange={(e) => setDependency(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,58 @@
import React, { useState, useEffect } from "react";
import { Container, Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import Navbar from "components/layout/Navbar";
import Footer from "components/layout/Footer";
import PackageTable from "components/table/PackageTable";
import LoginDialog from "components/dialogs/LoginDialog";
import { useAuth } from "hooks/useAuth";
import { useRepository } from "hooks/useRepository";
import { Client } from "api/client/AhrimanClient";
import { QueryKeys } from "api/QueryKeys";
import type { InfoResponse } from "api/types/InfoResponse";
export default function AppLayout(): React.JSX.Element {
const { setAuthState } = useAuth();
const { setRepositories } = useRepository();
const [loginOpen, setLoginOpen] = useState(false);
const { data: info } = useQuery<InfoResponse>({
queryKey: QueryKeys.info,
queryFn: () => Client.fetchInfo(),
staleTime: Infinity,
});
// Sync info to contexts when loaded
useEffect(() => {
if (info) {
setAuthState({ enabled: info.auth.enabled, username: info.auth.username ?? null });
setRepositories(info.repositories);
}
}, [info, setAuthState, setRepositories]);
return (
<Container maxWidth="xl">
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
<a href="https://github.com/arcan1s/ahriman" title="logo">
<img src="/static/logo.svg" width={30} height={30} alt="" />
</a>
<Box sx={{ flex: 1 }}>
<Navbar />
</Box>
</Box>
<PackageTable
autorefreshIntervals={info?.autorefresh_intervals ?? []}
/>
<Footer
version={info?.version ?? ""}
docsEnabled={info?.docs_enabled ?? false}
indexUrl={info?.index_url}
onLoginClick={() => setLoginOpen(true)}
/>
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
</Container>
);
}

View File

@@ -0,0 +1,79 @@
import type React from "react";
import { Box, Link, Button, Typography } from "@mui/material";
import GitHubIcon from "@mui/icons-material/GitHub";
import LogoutIcon from "@mui/icons-material/Logout";
import HomeIcon from "@mui/icons-material/Home";
import { useAuth } from "hooks/useAuth";
interface FooterProps {
version: string;
docsEnabled: boolean;
indexUrl?: string;
onLoginClick: () => void;
}
export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
const { enabled: authEnabled, username, logout } = useAuth();
const handleLogout = async (): Promise<void> => {
await logout();
window.location.href = "/";
};
return (
<Box
component="footer"
sx={{
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
alignItems: "center",
borderTop: 1,
borderColor: "divider",
mt: 2,
py: 1,
}}
>
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
<Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<GitHubIcon fontSize="small" />
<Typography variant="body2">ahriman {version}</Typography>
</Link>
<Link href="https://github.com/arcan1s/ahriman/releases" underline="hover" color="text.secondary" variant="body2">
releases
</Link>
<Link href="https://github.com/arcan1s/ahriman/issues" underline="hover" color="text.secondary" variant="body2">
report a bug
</Link>
{docsEnabled && (
<Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
api
</Link>
)}
</Box>
{indexUrl && (
<Box>
<Link href={indexUrl} underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<HomeIcon fontSize="small" />
<Typography variant="body2">repo index</Typography>
</Link>
</Box>
)}
{authEnabled && (
<Box>
{username ? (
<Button size="small" startIcon={<LogoutIcon />} onClick={() => void handleLogout()} sx={{ textTransform: "none" }}>
logout ({username})
</Button>
) : (
<Button size="small" onClick={onLoginClick} sx={{ textTransform: "none" }}>
login
</Button>
)}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,33 @@
import type React from "react";
import { Tabs, Tab, Box } from "@mui/material";
import { useRepository } from "hooks/useRepository";
export default function Navbar(): React.JSX.Element | null {
const { repositories, current, setCurrent } = useRepository();
if (repositories.length === 0 || !current) {
return null;
}
const currentIndex = repositories.findIndex(
(r) => r.architecture === current.architecture && r.repository === current.repository,
);
return (
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
value={currentIndex >= 0 ? currentIndex : 0}
onChange={(_, newValue: number) => setCurrent(repositories[newValue])}
variant="scrollable"
scrollButtons="auto"
>
{repositories.map((repo) => (
<Tab
key={`${repo.architecture}-${repo.repository}`}
label={`${repo.repository} (${repo.architecture})`}
/>
))}
</Tabs>
</Box>
);
}

View File

@@ -0,0 +1,192 @@
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
import ListIcon from "@mui/icons-material/List";
import { useQuery } from "@tanstack/react-query";
import hljs from "highlight.js/lib/core";
import plaintext from "highlight.js/lib/languages/plaintext";
import "highlight.js/styles/github.css";
import { Client } from "api/client/AhrimanClient";
import { QueryKeys } from "api/QueryKeys";
import { formatTimestamp } from "components/common/formatTimestamp";
import CopyButton from "components/common/CopyButton";
import type { LogRecord } from "api/types/LogRecord";
import type { RepositoryId } from "api/types/RepositoryId";
hljs.registerLanguage("plaintext", plaintext);
interface LogVersion {
version: string;
processId: string;
created: number;
logs: string;
}
interface BuildLogsTabProps {
packageBase: string;
repo: RepositoryId;
refetchInterval: number | false;
}
function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string {
return records
.filter(filter || Boolean)
.map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
.join("\n");
}
export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
const [activeIndex, setActiveIndex] = useState(0);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const codeRef = useRef<HTMLElement>(null);
const preRef = useRef<HTMLElement>(null);
const initialScrollDone = useRef(false);
const { data: allLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logs(packageBase, repo),
queryFn: () => Client.fetchLogs(packageBase, repo),
enabled: !!packageBase,
});
// Build version selectors from all logs
const versions = useMemo<LogVersion[]>(() => {
if (!allLogs || allLogs.length === 0) {
return [];
}
const grouped: Record<string, LogRecord & { minCreated: number }> = {};
for (const record of allLogs) {
const key = `${record.version}-${record.process_id}`;
if (!grouped[key]) {
grouped[key] = { ...record, minCreated: record.created };
} else {
grouped[key].minCreated = Math.min(grouped[key].minCreated, record.created);
}
}
return Object.values(grouped)
.sort((a, b) => b.minCreated - a.minCreated)
.map((v) => ({
version: v.version,
processId: v.process_id,
created: v.minCreated,
logs: convertLogs(
allLogs,
(r) => r.version === v.version && r.process_id === v.process_id,
),
}));
}, [allLogs]);
// Reset active index when data changes
const [prevAllLogs, setPrevAllLogs] = useState(allLogs);
if (allLogs !== prevAllLogs) {
setPrevAllLogs(allLogs);
setActiveIndex(0);
}
// Reset scroll tracking when logs data changes
useEffect(() => {
initialScrollDone.current = false;
}, [allLogs]);
// Refresh active version logs when using auto-refresh
const activeVersion = versions[activeIndex];
const { data: versionLogs } = useQuery<LogRecord[]>({
queryKey: activeVersion
? QueryKeys.logsVersion(packageBase, repo, activeVersion.version, activeVersion.processId)
: ["logs-none"],
queryFn: () =>
activeVersion
? Client.fetchLogs(packageBase, repo, activeVersion.version, activeVersion.processId)
: Promise.resolve([]),
enabled: !!activeVersion && !!refetchInterval,
refetchInterval,
});
// Derive displayed logs: prefer fresh polled data when available
const displayedLogs = useMemo(() => {
if (versionLogs && versionLogs.length > 0) {
return convertLogs(versionLogs);
}
return activeVersion?.logs ?? "";
}, [versionLogs, activeVersion]);
// Highlight code
useEffect(() => {
if (codeRef.current && displayedLogs) {
codeRef.current.textContent = displayedLogs;
delete codeRef.current.dataset.highlighted;
hljs.highlightElement(codeRef.current);
}
}, [displayedLogs]);
// Auto-scroll: always scroll to bottom on initial load, then only if already near bottom
useEffect(() => {
if (preRef.current && displayedLogs) {
const el = preRef.current;
if (!initialScrollDone.current) {
el.scrollTop = el.scrollHeight;
initialScrollDone.current = true;
} else {
const isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
if (isAtBottom) {
el.scrollTop = el.scrollHeight;
}
}
}
}, [displayedLogs]);
return (
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Box>
<Button
size="small"
startIcon={<ListIcon />}
onClick={(e) => setAnchorEl(e.currentTarget)}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
{versions.map((v, idx) => (
<MenuItem
key={`${v.version}-${v.processId}`}
selected={idx === activeIndex}
onClick={() => {
setActiveIndex(idx);
setAnchorEl(null);
initialScrollDone.current = false;
}}
>
<Typography variant="body2">{formatTimestamp(v.created)}</Typography>
</MenuItem>
))}
{versions.length === 0 && (
<MenuItem disabled>No logs available</MenuItem>
)}
</Menu>
</Box>
<Box sx={{ flex: 1, position: "relative" }}>
<Box
ref={preRef}
component="pre"
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
maxHeight: 400,
fontSize: "0.8rem",
fontFamily: "monospace",
}}
>
<code ref={codeRef} className="language-plaintext" />
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => displayedLogs} />
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,60 @@
import React, { useEffect, useRef } from "react";
import { Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import hljs from "highlight.js/lib/core";
import diff from "highlight.js/lib/languages/diff";
import "highlight.js/styles/github.css";
import { Client } from "api/client/AhrimanClient";
import { QueryKeys } from "api/QueryKeys";
import CopyButton from "components/common/CopyButton";
import type { Changes } from "api/types/Changes";
import type { RepositoryId } from "api/types/RepositoryId";
hljs.registerLanguage("diff", diff);
interface ChangesTabProps {
packageBase: string;
repo: RepositoryId;
}
export default function ChangesTab({ packageBase, repo }: ChangesTabProps): React.JSX.Element {
const codeRef = useRef<HTMLElement>(null);
const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repo),
queryFn: () => Client.fetchChanges(packageBase, repo),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
useEffect(() => {
if (codeRef.current) {
codeRef.current.textContent = changesText;
delete codeRef.current.dataset.highlighted;
hljs.highlightElement(codeRef.current);
}
}, [changesText]);
return (
<Box sx={{ position: "relative", mt: 1 }}>
<Box
component="pre"
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
maxHeight: 400,
fontSize: "0.8rem",
fontFamily: "monospace",
}}
>
<code ref={codeRef} className="language-diff" />
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => changesText} />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,60 @@
import type React from "react";
import { Box } from "@mui/material";
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
import { useQuery } from "@tanstack/react-query";
import EventDurationLineChart from "components/charts/EventDurationLineChart";
import { Client } from "api/client/AhrimanClient";
import { QueryKeys } from "api/QueryKeys";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { Event } from "api/types/Event";
import type { RepositoryId } from "api/types/RepositoryId";
interface EventsTabProps {
packageBase: string;
repo: RepositoryId;
}
interface EventRow {
id: number;
timestamp: string;
event: string;
message: string;
}
const columns: GridColDef<EventRow>[] = [
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
{ field: "event", headerName: "event", flex: 1 },
{ field: "message", headerName: "description", flex: 2 },
];
export default function EventsTab({ packageBase, repo }: EventsTabProps): React.JSX.Element {
const { data: events = [] } = useQuery<Event[]>({
queryKey: QueryKeys.events(repo, packageBase),
queryFn: () => Client.fetchEvents(repo, packageBase, 30),
enabled: !!packageBase,
});
const rows: EventRow[] = events.map((e, idx) => ({
id: idx,
timestamp: formatTimestamp(e.created),
event: e.event,
message: e.message ?? "",
}));
return (
<Box sx={{ mt: 1 }}>
<EventDurationLineChart events={events} />
<DataGrid
rows={rows}
columns={columns}
density="compact"
initialState={{
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
}}
pageSizeOptions={[10, 25]}
sx={{ height: 300, mt: 1 }}
disableRowSelectionOnClick
/>
</Box>
);
}

View File

@@ -0,0 +1,317 @@
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
DataGrid,
GridToolbarQuickFilter,
GridToolbarFilterButton,
type GridColDef,
type GridFilterModel,
type GridRowSelectionModel,
type GridRenderCellParams,
} from "@mui/x-data-grid";
import { Box, Link, Stack } from "@mui/material";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell";
import DashboardDialog from "components/dialogs/DashboardDialog";
import PackageAddDialog from "components/dialogs/PackageAddDialog";
import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
import KeyImportDialog from "components/dialogs/KeyImportDialog";
import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
import { useRepository } from "hooks/useRepository";
import { useAuth } from "hooks/useAuth";
import { useNotification } from "hooks/useNotification";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useLocalStorage } from "hooks/useLocalStorage";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "api/QueryKeys";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { InternalStatus } from "api/types/InternalStatus";
import type { PackageRow } from "api/types/PackageRow";
import type { PackageStatus } from "api/types/PackageStatus";
interface PackageTableProps {
autorefreshIntervals: AutoRefreshInterval[];
}
function extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
return [
...new Set(
Object.values(pkg.packages)
.flatMap((p) => p[property] ?? []),
),
].sort();
}
function toRow(ps: PackageStatus): PackageRow {
return {
id: ps.package.base,
base: ps.package.base,
webUrl: ps.package.remote.web_url ?? undefined,
version: ps.package.version,
packages: Object.keys(ps.package.packages).sort(),
groups: extractListProperties(ps.package, "groups"),
licenses: extractListProperties(ps.package, "licenses"),
packager: ps.package.packager ?? "",
timestamp: formatTimestamp(ps.status.timestamp),
timestampValue: ps.status.timestamp,
status: ps.status.status,
};
}
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function SearchToolbar(): React.JSX.Element {
return (
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 1, py: 0.5 }}>
<GridToolbarFilterButton />
<GridToolbarQuickFilter debounceMs={300} sx={{ flex: 1 }} />
</Stack>
);
}
export default function PackageTable({ autorefreshIntervals }: PackageTableProps): React.JSX.Element {
const { current } = useRepository();
const { enabled: authEnabled, username } = useAuth();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const hasAuth = !authEnabled || username !== null;
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval);
const [selectionModel, setSelectionModel] = useState<GridRowSelectionModel>([]);
const [dialogOpen, setDialogOpen] = useState<string | null>(null);
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
pageSize: 10,
page: 0,
});
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
"ahriman-packages-columns",
{ groups: false, licenses: false, packager: false },
);
const [filterModel, setFilterModel] = useLocalStorage<GridFilterModel>(
"ahriman-packages-filters",
{ items: [] },
);
// Pause auto-refresh when dialog is open
const isDialogOpen = dialogOpen !== null || selectedPackage !== null;
const setPaused = autoRefresh.setPaused;
useEffect(() => {
setPaused(isDialogOpen);
}, [isDialogOpen, setPaused]);
const { data: packages = [], isLoading } = useQuery<PackageStatus[]>({
queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryFn: () => (current ? Client.fetchPackages(current) : Promise.resolve([])),
enabled: !!current,
refetchInterval: autoRefresh.refetchInterval,
});
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: () => Client.fetchStatus(current!),
enabled: !!current,
refetchInterval: autoRefresh.refetchInterval,
});
const rows = useMemo(() => packages.map(toRow), [packages]);
const handleReload = useCallback(() => {
if (!current) {
return;
}
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
}, [current, queryClient]);
const handleUpdate = useCallback(async () => {
if (!current) {
return;
}
const selected = selectionModel as string[];
try {
if (selected.length === 0) {
await Client.updatePackages(current, { packages: [] });
showSuccess("Success", "Repository update has been run");
} else {
await Client.addPackages(current, { packages: selected });
showSuccess("Success", `Run update for packages ${selected.join(", ")}`);
}
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Packages update failed: ${detail}`);
}
}, [current, selectionModel, showSuccess, showError]);
const handleRefreshDb = useCallback(async () => {
if (!current) {
return;
}
try {
await Client.updatePackages(current, { packages: [], refresh: true, aur: false, local: false, manual: false });
showSuccess("Success", "Pacman database update has been requested");
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Could not update pacman databases: ${detail}`);
}
}, [current, showSuccess, showError]);
const handleRemove = useCallback(async () => {
if (!current) {
return;
}
const selected = selectionModel as string[];
if (selected.length === 0) {
return;
}
try {
await Client.removePackages(current, selected);
showSuccess("Success", `Packages ${selected.join(", ")} have been removed`);
setSelectionModel([]);
} catch (e) {
const detail = e instanceof ApiError ? e.detail : String(e);
showError("Action failed", `Could not remove packages: ${detail}`);
}
}, [current, selectionModel, showSuccess, showError]);
const columns: GridColDef<PackageRow>[] = useMemo(
() => [
{
field: "base",
headerName: "package base",
flex: 1,
minWidth: 150,
renderCell: (params: GridRenderCellParams<PackageRow>) =>
params.row.webUrl ? (
<Link href={params.row.webUrl} target="_blank" rel="noopener" underline="hover">
{params.value as string}
</Link>
) : (
params.value as string
),
},
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
{
field: "packages",
headerName: "packages",
flex: 1,
minWidth: 120,
valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.packages ?? []).map((item, i, arr) => (
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
)),
sortComparator: (v1: string, v2: string) => v1.localeCompare(v2),
},
{
field: "groups",
headerName: "groups",
width: 150,
valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.groups ?? []).map((item, i, arr) => (
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
)),
},
{
field: "licenses",
headerName: "licenses",
width: 150,
valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.licenses ?? []).map((item, i, arr) => (
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
)),
},
{ field: "packager", headerName: "packager", width: 150 },
{
field: "timestamp",
headerName: "last update",
width: 180,
align: "right",
headerAlign: "right",
},
{
field: "status",
headerName: "status",
width: 120,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />,
},
],
[],
);
return (
<Box>
<PackageTableToolbar
hasSelection={(selectionModel as string[]).length > 0}
hasAuth={hasAuth}
repoStatus={status?.status.status}
autorefreshIntervals={autorefreshIntervals}
autoRefreshEnabled={autoRefresh.enabled}
autoRefreshInterval={autoRefresh.interval}
onAutoRefreshToggle={autoRefresh.setEnabled}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
onDashboardClick={() => setDialogOpen("dashboard")}
onAddClick={() => setDialogOpen("add")}
onUpdateClick={() => void handleUpdate()}
onRefreshDbClick={() => void handleRefreshDb()}
onRebuildClick={() => setDialogOpen("rebuild")}
onRemoveClick={() => void handleRemove()}
onKeyImportClick={() => setDialogOpen("keyImport")}
onReloadClick={handleReload}
/>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
getRowHeight={() => "auto"}
checkboxSelection
disableRowSelectionOnClick
rowSelectionModel={selectionModel}
onRowSelectionModelChange={setSelectionModel}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={PAGE_SIZE_OPTIONS}
columnVisibilityModel={columnVisibility}
onColumnVisibilityModelChange={setColumnVisibility}
filterModel={filterModel}
onFilterModelChange={setFilterModel}
slots={{ toolbar: SearchToolbar }}
initialState={{
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
}}
onRowClick={(params: { row: PackageRow }, event) => {
const target = event.target as HTMLElement;
// Don't open info dialog when clicking checkbox or link
if (target.closest("input[type=\"checkbox\"]") || target.closest("a")) {
return;
}
setSelectedPackage(params.row.id);
}}
sx={{
"& .MuiDataGrid-row": { cursor: "pointer" },
height: 600,
}}
density="compact"
/>
<DashboardDialog open={dialogOpen === "dashboard"} onClose={() => setDialogOpen(null)} />
<PackageAddDialog open={dialogOpen === "add"} onClose={() => setDialogOpen(null)} />
<PackageRebuildDialog open={dialogOpen === "rebuild"} onClose={() => setDialogOpen(null)} />
<KeyImportDialog open={dialogOpen === "keyImport"} onClose={() => setDialogOpen(null)} />
<PackageInfoDialog
packageBase={selectedPackage}
open={selectedPackage !== null}
onClose={() => setSelectedPackage(null)}
autorefreshIntervals={autorefreshIntervals}
/>
</Box>
);
}

View File

@@ -0,0 +1,133 @@
import React, { useState } from "react";
import { Button, Menu, MenuItem, Box, Tooltip, IconButton, Divider } from "@mui/material";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import InventoryIcon from "@mui/icons-material/Inventory";
import AddIcon from "@mui/icons-material/Add";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import DownloadIcon from "@mui/icons-material/Download";
import ReplayIcon from "@mui/icons-material/Replay";
import DeleteIcon from "@mui/icons-material/Delete";
import VpnKeyIcon from "@mui/icons-material/VpnKey";
import RefreshIcon from "@mui/icons-material/Refresh";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { BuildStatus } from "api/types/BuildStatus";
import { StatusColors } from "theme/status/StatusColors";
interface PackageTableToolbarProps {
hasSelection: boolean;
hasAuth: boolean;
repoStatus?: BuildStatus;
autorefreshIntervals: AutoRefreshInterval[];
autoRefreshEnabled: boolean;
autoRefreshInterval: number;
onAutoRefreshToggle: (enabled: boolean) => void;
onAutoRefreshIntervalChange: (interval: number) => void;
onDashboardClick: () => void;
onAddClick: () => void;
onUpdateClick: () => void;
onRefreshDbClick: () => void;
onRebuildClick: () => void;
onRemoveClick: () => void;
onKeyImportClick: () => void;
onReloadClick: () => void;
}
export default function PackageTableToolbar({
hasSelection,
hasAuth,
repoStatus,
autorefreshIntervals,
autoRefreshEnabled,
autoRefreshInterval,
onAutoRefreshToggle,
onAutoRefreshIntervalChange,
onDashboardClick,
onAddClick,
onUpdateClick,
onRefreshDbClick,
onRebuildClick,
onRemoveClick,
onKeyImportClick,
onReloadClick,
}: PackageTableToolbarProps): React.JSX.Element {
const [packagesAnchorEl, setPackagesAnchorEl] = useState<null | HTMLElement>(null);
return (
<Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
<Tooltip title="System health">
<IconButton
onClick={onDashboardClick}
sx={{
borderColor: repoStatus ? StatusColors[repoStatus] : undefined,
borderWidth: 1,
borderStyle: "solid",
color: repoStatus ? StatusColors[repoStatus] : undefined,
}}
>
<InfoOutlinedIcon />
</IconButton>
</Tooltip>
{hasAuth && (
<>
<Button
variant="contained"
startIcon={<InventoryIcon />}
onClick={(e) => setPackagesAnchorEl(e.currentTarget)}
>
packages
</Button>
<Menu
anchorEl={packagesAnchorEl}
open={Boolean(packagesAnchorEl)}
onClose={() => setPackagesAnchorEl(null)}
>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onAddClick();
}}>
<AddIcon fontSize="small" sx={{ mr: 1 }} /> add
</MenuItem>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onUpdateClick();
}}>
<PlayArrowIcon fontSize="small" sx={{ mr: 1 }} /> update
</MenuItem>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onRefreshDbClick();
}}>
<DownloadIcon fontSize="small" sx={{ mr: 1 }} /> update pacman databases
</MenuItem>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onRebuildClick();
}}>
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
</MenuItem>
<Divider />
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onRemoveClick();
}} disabled={!hasSelection}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
</MenuItem>
</Menu>
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={onKeyImportClick}>
import key
</Button>
</>
)}
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={onReloadClick}>
reload
</Button>
<AutoRefreshControl
intervals={autorefreshIntervals}
enabled={autoRefreshEnabled}
currentInterval={autoRefreshInterval}
onToggle={onAutoRefreshToggle}
onIntervalChange={onAutoRefreshIntervalChange}
/>
</Box>
);
}

View File

@@ -0,0 +1,22 @@
import type React from "react";
import { Chip } from "@mui/material";
import type { BuildStatus } from "api/types/BuildStatus";
import { StatusColors } from "theme/status/StatusColors";
interface StatusCellProps {
status: BuildStatus;
}
export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
return (
<Chip
label={status}
size="small"
sx={{
backgroundColor: StatusColors[status],
color: "common.white",
fontWeight: 500,
}}
/>
);
}

View File

@@ -0,0 +1,14 @@
import { createContext } from "react";
interface AuthState {
enabled: boolean;
username: string | null;
}
export interface AuthContextValue extends AuthState {
setAuthState: (state: AuthState) => void;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);

View File

@@ -0,0 +1,23 @@
import React, { useState, useCallback, type ReactNode } from "react";
import { Client } from "api/client/AhrimanClient";
import { AuthContext } from "contexts/AuthContext";
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [state, setState] = useState({ enabled: false, username: null as string | null });
const login = useCallback(async (username: string, password: string) => {
await Client.login({ username, password });
setState((prev) => ({ ...prev, username }));
}, []);
const doLogout = useCallback(async () => {
await Client.logout();
setState((prev) => ({ ...prev, username: null }));
}, []);
return (
<AuthContext.Provider value={{ ...state, setAuthState: setState, login, logout: doLogout }}>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,8 @@
import { createContext } from "react";
export interface NotificationContextValue {
showSuccess: (title: string, message: string) => void;
showError: (title: string, message: string) => void;
}
export const NotificationContext = createContext<NotificationContextValue | null>(null);

View File

@@ -0,0 +1,53 @@
import React, { useState, useCallback, useRef, type ReactNode } from "react";
import { Snackbar, Alert, type AlertColor } from "@mui/material";
import { NotificationContext } from "contexts/NotificationContext";
interface Notification {
id: number;
title: string;
message: string;
severity: AlertColor;
}
export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element {
const nextId = useRef(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = useCallback((title: string, message: string, severity: AlertColor) => {
const id = nextId.current++;
setNotifications((prev) => [...prev, { id, title, message, severity }]);
}, []);
const removeNotification = useCallback((id: number) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const showSuccess = useCallback(
(title: string, message: string) => addNotification(title, message, "success"),
[addNotification],
);
const showError = useCallback(
(title: string, message: string) => addNotification(title, message, "error"),
[addNotification],
);
return (
<NotificationContext.Provider value={{ showSuccess, showError }}>
{children}
{notifications.map((n) => (
<Snackbar
key={n.id}
open
autoHideDuration={5000}
onClose={() => removeNotification(n.id)}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
>
<Alert onClose={() => removeNotification(n.id)} severity={n.severity} variant="filled" sx={{ width: "100%", maxWidth: 500 }}>
<strong>{n.title}</strong>
{n.message && ` - ${n.message}`}
</Alert>
</Snackbar>
))}
</NotificationContext.Provider>
);
}

View File

@@ -0,0 +1,11 @@
import { createContext } from "react";
import type { RepositoryId } from "api/types/RepositoryId";
export interface RepositoryContextValue {
repositories: RepositoryId[];
current: RepositoryId | null;
setRepositories: (repos: RepositoryId[]) => void;
setCurrent: (repo: RepositoryId) => void;
}
export const RepositoryContext = createContext<RepositoryContextValue | null>(null);

View File

@@ -0,0 +1,47 @@
import React, { useState, useCallback, useEffect, type ReactNode } from "react";
import type { RepositoryId } from "api/types/RepositoryId";
import { RepositoryContext } from "contexts/RepositoryContext";
function repoId(repo: RepositoryId): string {
return `${repo.architecture}-${repo.repository}`;
}
export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const [current, setCurrentState] = useState<RepositoryId | null>(null);
const setCurrent = useCallback((repo: RepositoryId) => {
setCurrentState(repo);
window.location.hash = repoId(repo);
}, []);
// Sync current repository when repositories list changes
const [prevRepositories, setPrevRepositories] = useState(repositories);
if (repositories !== prevRepositories) {
setPrevRepositories(repositories);
if (repositories.length > 0) {
const hash = window.location.hash.replace("#", "");
const match = repositories.find((r) => repoId(r) === hash);
setCurrentState(match || repositories[0]);
}
}
// Listen for hash changes
useEffect(() => {
const handler = (): void => {
const hash = window.location.hash.replace("#", "");
const match = repositories.find((r) => repoId(r) === hash);
if (match) {
setCurrentState(match);
}
};
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
}, [repositories]);
return (
<RepositoryContext.Provider value={{ repositories, current, setRepositories, setCurrent }}>
{children}
</RepositoryContext.Provider>
);
}

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { AuthContext, type AuthContextValue } from "contexts/AuthContext";
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within AuthProvider");
}
return ctx;
}

View File

@@ -0,0 +1,52 @@
import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
import { useLocalStorage } from "hooks/useLocalStorage";
interface AutoRefreshState {
enabled: boolean;
interval: number;
}
interface AutoRefreshResult {
enabled: boolean;
interval: number;
paused: boolean;
refetchInterval: number | false;
setEnabled: (enabled: boolean) => void;
setInterval: (interval: number) => void;
setPaused: Dispatch<SetStateAction<boolean>>;
}
export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult {
const [stored, setStored] = useLocalStorage<AutoRefreshState>(`ahriman-${key}`, {
enabled: defaultInterval > 0,
interval: defaultInterval,
});
const [paused, setPaused] = useState(false);
const refetchInterval: number | false = stored.enabled && !paused && stored.interval > 0 ? stored.interval : false;
const setEnabled = useCallback(
(enabled: boolean) => {
setStored((prev) => ({ ...prev, enabled }));
},
[setStored],
);
const setInterval = useCallback(
(interval: number) => {
setStored({ enabled: true, interval });
},
[setStored],
);
return {
enabled: stored.enabled,
interval: stored.interval,
paused,
refetchInterval,
setEnabled,
setInterval,
setPaused,
};
}

View File

@@ -0,0 +1,12 @@
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,25 @@
import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
export function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(value) => {
setStoredValue((prev) => {
const nextValue = value instanceof Function ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(nextValue));
return nextValue;
});
},
[key],
);
return [storedValue, setValue];
}

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { NotificationContext, type NotificationContextValue } from "contexts/NotificationContext";
export function useNotification(): NotificationContextValue {
const ctx = useContext(NotificationContext);
if (!ctx) {
throw new Error("useNotification must be used within NotificationProvider");
}
return ctx;
}

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { RepositoryContext, type RepositoryContextValue } from "contexts/RepositoryContext";
export function useRepository(): RepositoryContextValue {
const ctx = useContext(RepositoryContext);
if (!ctx) {
throw new Error("useRepository must be used within RepositoryProvider");
}
return ctx;
}

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,67 @@
import { createTheme } from "@mui/material/styles";
// Bootswatch Cosmo-inspired palette
const Theme = createTheme({
palette: {
primary: {
main: "#2780e3",
contrastText: "#fff",
},
secondary: {
main: "#373a3c",
contrastText: "#fff",
},
success: {
main: "#3fb618",
contrastText: "#fff",
},
error: {
main: "#ff0039",
contrastText: "#fff",
},
warning: {
main: "#ff7518",
contrastText: "#fff",
},
info: {
main: "#9954bb",
contrastText: "#fff",
},
background: {
default: "#fff",
paper: "#fff",
},
text: {
primary: "#373a3c",
secondary: "#6c757d",
},
},
typography: {
fontFamily: "\"Source Sans Pro\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
fontSize: 14,
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: "none",
},
},
},
MuiTab: {
styleOverrides: {
root: {
textTransform: "none",
},
},
},
MuiDialog: {
defaultProps: {
maxWidth: "lg",
fullWidth: true,
},
},
},
});
export default Theme;

View File

@@ -0,0 +1,20 @@
import { alpha } from "@mui/material/styles";
import type { BuildStatus } from "api/types/BuildStatus";
const base: Record<BuildStatus, string> = {
unknown: "#373a3c",
pending: "#ff7518",
building: "#ff7518",
failed: "#ff0039",
success: "#3fb618",
};
export const StatusColors = base;
export const StatusBackgrounds: Record<BuildStatus, string> = Object.fromEntries(
Object.entries(base).map(([k, v]) => [k, alpha(v, 0.1)]),
) as Record<BuildStatus, string>;
export const StatusHeaderStyles: Record<BuildStatus, { backgroundColor: string; color: string }> = Object.fromEntries(
Object.entries(base).map(([k, v]) => [k, { backgroundColor: v, color: "#fff" }]),
) as Record<BuildStatus, { backgroundColor: string; color: string }>;

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": "src"
},
"include": ["src"]
}

41
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path";
import fs from "fs";
import type { Plugin } from "vite";
function renameIndexHtml(): Plugin {
return {
name: "rename-index-html",
closeBundle() {
const outDir = path.resolve(__dirname, "../package/share/ahriman/templates");
const src = path.join(outDir, "index.html");
const dest = path.join(outDir, "build-status-v2.jinja2");
if (fs.existsSync(src)) {
fs.renameSync(src, dest);
}
},
};
}
export default defineConfig({
plugins: [react(), tsconfigPaths(), renameIndexHtml()],
base: "/",
build: {
outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
emptyOutDir: false,
rollupOptions: {
output: {
entryFileNames: "static/[name].js",
chunkFileNames: "static/[name].js",
assetFileNames: "static/[name].[ext]",
},
},
},
server: {
proxy: {
"/api": "http://localhost:8080",
},
},
});

View File

@@ -46,6 +46,8 @@ host = 127.0.0.1
;service_only = no ;service_only = no
; Path to directory with static files. ; Path to directory with static files.
static_path = ${templates}/static static_path = ${templates}/static
; Jinja2 template name for the index page.
;template = build-status.jinja2
; List of directories with templates. ; List of directories with templates.
templates[] = ${prefix}/share/ahriman/templates templates[] = ${prefix}/share/ahriman/templates
; Path to unix socket. If none set, unix socket will be disabled. ; Path to unix socket. If none set, unix socket will be disabled.

View File

@@ -398,6 +398,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"path_exists": True, "path_exists": True,
"path_type": "dir", "path_type": "dir",
}, },
"template": {
"type": "string",
"empty": False,
},
"templates": { "templates": {
"type": "list", "type": "list",
"coerce": "list", "coerce": "list",

View File

@@ -35,7 +35,7 @@ from enum import Enum
from filelock import FileLock from filelock import FileLock
from pathlib import Path from pathlib import Path
from pwd import getpwuid from pwd import getpwuid
from typing import Any, IO, TypeVar from typing import Any, IO, TypeVar, cast
from ahriman.core.exceptions import CalledProcessError, OptionError, UnsafeRunError from ahriman.core.exceptions import CalledProcessError, OptionError, UnsafeRunError
from ahriman.core.types import Comparable from ahriman.core.types import Comparable
@@ -285,16 +285,17 @@ def filelock(path: Path) -> Iterator[FileLock]:
lock_path.unlink(missing_ok=True) lock_path.unlink(missing_ok=True)
def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str, Any]: def filter_json(source: T, known_fields: Iterable[str] | None = None) -> T:
""" """
filter json object by fields used for json-to-object conversion recursively filter json object removing ``None`` values and optionally filtering by known fields
Args: Args:
source(dict[str, Any]): raw json object source(T): raw json object (dict, list, or scalar)
known_fields(Iterable[str]): list of fields which have to be known for the target object known_fields(Iterable[str] | None, optional): list of fields which have to be known for the target object
(Default value = None)
Returns: Returns:
dict[str, Any]: json object without unknown and empty fields T: json without ``None`` values
Examples: Examples:
This wrapper is mainly used for the dataclasses, thus the flow must be something like this:: This wrapper is mainly used for the dataclasses, thus the flow must be something like this::
@@ -306,7 +307,15 @@ def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str
>>> properties = filter_json(dump, known_fields) >>> properties = filter_json(dump, known_fields)
>>> package = Package(**properties) >>> package = Package(**properties)
""" """
return {key: value for key, value in source.items() if key in known_fields and value is not None} if isinstance(source, dict):
return cast(T, {
key: filter_json(value)
for key, value in source.items()
if value is not None and (known_fields is None or key in known_fields)
})
if isinstance(source, list):
return cast(T, [filter_json(value) for value in source if value is not None])
return source
def full_version(epoch: str | int | None, pkgver: str, pkgrel: str) -> str: def full_version(epoch: str | int | None, pkgver: str, pkgrel: str) -> str:

View File

@@ -19,7 +19,9 @@
# #
from ahriman.web.schemas.any_schema import AnySchema from ahriman.web.schemas.any_schema import AnySchema
from ahriman.web.schemas.aur_package_schema import AURPackageSchema from ahriman.web.schemas.aur_package_schema import AURPackageSchema
from ahriman.web.schemas.auth_info_schema import AuthInfoSchema
from ahriman.web.schemas.auth_schema import AuthSchema from ahriman.web.schemas.auth_schema import AuthSchema
from ahriman.web.schemas.auto_refresh_interval_schema import AutoRefreshIntervalSchema
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
from ahriman.web.schemas.changes_schema import ChangesSchema from ahriman.web.schemas.changes_schema import ChangesSchema
from ahriman.web.schemas.configuration_schema import ConfigurationSchema from ahriman.web.schemas.configuration_schema import ConfigurationSchema
@@ -30,6 +32,7 @@ from ahriman.web.schemas.event_schema import EventSchema
from ahriman.web.schemas.event_search_schema import EventSearchSchema from ahriman.web.schemas.event_search_schema import EventSearchSchema
from ahriman.web.schemas.file_schema import FileSchema from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.info_schema import InfoSchema from ahriman.web.schemas.info_schema import InfoSchema
from ahriman.web.schemas.info_v2_schema import InfoV2Schema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema from ahriman.web.schemas.internal_status_schema import InternalStatusSchema
from ahriman.web.schemas.log_schema import LogSchema from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.login_schema import LoginSchema from ahriman.web.schemas.login_schema import LoginSchema

View File

@@ -0,0 +1,36 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.web.apispec import Schema, fields
class AuthInfoSchema(Schema):
"""
authorization information schema
"""
control = fields.String(required=True, metadata={
"description": "HTML control for login interface",
})
enabled = fields.Boolean(required=True, metadata={
"description": "Whether authentication is enabled or not",
})
username = fields.String(metadata={
"description": "Currently authenticated username if any",
})

View File

@@ -0,0 +1,36 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.web.apispec import Schema, fields
class AutoRefreshIntervalSchema(Schema):
"""
auto refresh interval schema
"""
interval = fields.Integer(required=True, metadata={
"description": "Auto refresh interval in milliseconds",
})
is_active = fields.Boolean(required=True, metadata={
"description": "Whether this interval is the default active one",
})
text = fields.String(required=True, metadata={
"description": "Human readable interval description",
})

View File

@@ -27,7 +27,7 @@ class InfoSchema(Schema):
response service information schema response service information schema
""" """
auth = fields.Boolean(dump_default=False, required=True, metadata={ auth = fields.Boolean(required=True, metadata={
"description": "Whether authentication is enabled or not", "description": "Whether authentication is enabled or not",
}) })
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={ repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={

View File

@@ -0,0 +1,50 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman import __version__
from ahriman.web.apispec import Schema, fields
from ahriman.web.schemas.auth_info_schema import AuthInfoSchema
from ahriman.web.schemas.auto_refresh_interval_schema import AutoRefreshIntervalSchema
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
class InfoV2Schema(Schema):
"""
response service information schema
"""
auth = fields.Nested(AuthInfoSchema(), required=True, metadata={
"description": "Authorization descriptor",
})
autorefresh_intervals = fields.Nested(AutoRefreshIntervalSchema(many=True), metadata={
"description": "Available auto refresh intervals",
})
docs_enabled = fields.Boolean(metadata={
"description": "Whether API documentation is enabled",
})
index_url = fields.String(metadata={
"description": "URL to the repository index page",
})
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
"description": "List of loaded repositories",
})
version = fields.String(required=True, metadata={
"description": "Service version",
"example": __version__,
})

View File

@@ -29,6 +29,10 @@ class RepositoryIdSchema(Schema):
"description": "Repository architecture", "description": "Repository architecture",
"example": "x86_64", "example": "x86_64",
}) })
id = fields.String(metadata={
"description": "Unique repository identifier",
"example": "aur-x86_64",
})
repository = fields.String(metadata={ repository = fields.String(metadata={
"description": "Repository name", "description": "Repository name",
"example": "aur", "example": "aur",

View File

@@ -0,0 +1,69 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from collections.abc import Callable
from typing import Any
from ahriman import __version__
from ahriman.core.auth.helpers import authorized_userid
from ahriman.core.types import Comparable
from ahriman.core.utils import pretty_interval
from ahriman.web.apispec import aiohttp_apispec
from ahriman.web.views.base import BaseView
async def server_info(view: BaseView) -> dict[str, Any]:
"""
generate server info which can be used in responses directly
Args:
view(BaseView): view of the request
Returns:
dict[str, Any]: server info as a json response
"""
autorefresh_intervals = [
{
"interval": interval * 1000, # milliseconds
"is_active": index == 0, # first element is always default
"text": pretty_interval(interval),
}
for index, interval in enumerate(view.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
]
comparator: Callable[[dict[str, Any]], Comparable] = lambda interval: interval["interval"]
return {
"auth": {
"control": view.validator.auth_control,
"enabled": view.validator.enabled,
"username": await authorized_userid(view.request),
},
"autorefresh_intervals": sorted(autorefresh_intervals, key=comparator),
"docs_enabled": aiohttp_apispec is not None,
"index_url": view.configuration.get("web", "index_url", fallback=None),
"repositories": [
{
"id": repository_id.id,
**repository_id.view(),
}
for repository_id in sorted(view.services)
],
"version": __version__,
}

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import Response, json_response from aiohttp.web import Response
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar from typing import ClassVar
@@ -96,4 +96,4 @@ class SwaggerView(BaseView):
for key, value in schema.items() for key, value in schema.items()
} }
return json_response(spec) return self.json_response(spec)

View File

@@ -17,10 +17,10 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, StreamResponse, View from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, Response, StreamResponse, View, json_response
from aiohttp_cors import CorsViewMixin from aiohttp_cors import CorsViewMixin
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import ClassVar, TypeVar from typing import Any, ClassVar, TypeVar
from ahriman.core.auth import Auth from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@@ -29,6 +29,7 @@ from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
from ahriman.core.utils import filter_json
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
@@ -162,6 +163,20 @@ class BaseView(View, CorsViewMixin):
raise KeyError(f"Key {key} is missing or empty") from None raise KeyError(f"Key {key} is missing or empty") from None
return value return value
@staticmethod
def json_response(data: dict[str, Any] | list[Any], **kwargs: Any) -> Response:
"""
filter and convert data and return :class:`aiohttp.web.Response` object
Args:
data(dict[str, Any]): response in json format
**kwargs(Any): keyword arguments for :func:`aiohttp.web.json_response` function
Returns:
Response: generated response object
"""
return json_response(filter_json(data), **kwargs)
# pylint: disable=not-callable,protected-access # pylint: disable=not-callable,protected-access
async def head(self) -> StreamResponse: async def head(self) -> StreamResponse:
""" """

View File

@@ -19,12 +19,11 @@
# #
import aiohttp_jinja2 import aiohttp_jinja2
from typing import Any, ClassVar from aiohttp.web import Response
from typing import ClassVar
from ahriman.core.auth.helpers import authorized_userid
from ahriman.core.utils import pretty_interval
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec import aiohttp_apispec from ahriman.web.server_info import server_info
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@@ -48,6 +47,7 @@ class IndexView(BaseView):
* id - unique repository identifier, string, required * id - unique repository identifier, string, required
* repository - repository name, string, required * repository - repository name, string, required
* architecture - repository architecture, string, required * architecture - repository architecture, string, required
* version - service version, string, required
Attributes: Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
@@ -56,41 +56,14 @@ class IndexView(BaseView):
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/", "/index.html"] ROUTES = ["/", "/index.html"]
@aiohttp_jinja2.template("build-status.jinja2") async def get(self) -> Response:
async def get(self) -> dict[str, Any]:
""" """
process get request. No parameters supported here process get request. No parameters supported here
Returns: Returns:
dict[str, Any]: parameters for jinja template Response: 200 with rendered index page
""" """
auth_username = await authorized_userid(self.request) context = await server_info(self)
auth = {
"control": self.validator.auth_control,
"enabled": self.validator.enabled,
"username": auth_username,
}
autorefresh_intervals = [ template = self.configuration.get("web", "template", fallback="build-status.jinja2")
{ return await aiohttp_jinja2.render_template_async(template, self.request, context)
"interval": interval * 1000, # milliseconds
"is_active": index == 0, # first element is always default
"text": pretty_interval(interval),
}
for index, interval in enumerate(self.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
]
return {
"auth": auth,
"autorefresh_intervals": sorted(autorefresh_intervals, key=lambda interval: interval["interval"]),
"docs_enabled": aiohttp_apispec is not None,
"index_url": self.configuration.get("web", "index_url", fallback=None),
"repositories": [
{
"id": repository.id,
**repository.view(),
}
for repository in sorted(self.services)
]
}

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar from typing import ClassVar
from ahriman.models.event import Event from ahriman.models.event import Event
@@ -70,7 +70,7 @@ class EventsView(BaseView):
events = self.service().event_get(event, object_id, from_date, to_date, limit, offset) events = self.service().event_get(event, object_id, from_date, to_date, limit, offset)
response = [event.view() for event in events] response = [event.view() for event in events]
return json_response(response) return self.json_response(response)
@apidocs( @apidocs(
tags=["Audit log"], tags=["Audit log"],

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar from typing import ClassVar
@@ -78,7 +78,7 @@ class WorkersView(BaseView):
comparator: Callable[[Worker], Comparable] = lambda item: item.identifier comparator: Callable[[Worker], Comparable] = lambda item: item.identifier
response = [worker.view() for worker in sorted(workers, key=comparator)] response = [worker.view() for worker in sorted(workers, key=comparator)]
return json_response(response) return self.json_response(response)
@apidocs( @apidocs(
tags=["Distributed"], tags=["Distributed"],

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar from typing import ClassVar
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
@@ -65,7 +65,7 @@ class ChangesView(StatusViewGuard, BaseView):
changes = self.service(package_base=package_base).package_changes_get(package_base) changes = self.service(package_base=package_base).package_changes_get(package_base)
return json_response(changes.view()) return self.json_response(changes.view())
@apidocs( @apidocs(
tags=["Packages"], tags=["Packages"],

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar from typing import ClassVar
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
@@ -65,7 +65,7 @@ class DependenciesView(StatusViewGuard, BaseView):
dependencies = self.service(package_base=package_base).package_dependencies_get(package_base) dependencies = self.service(package_base=package_base).package_dependencies_get(package_base)
return json_response(dependencies.view()) return self.json_response(dependencies.view())
@apidocs( @apidocs(
tags=["Packages"], tags=["Packages"],

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
@@ -99,7 +99,7 @@ class LogsView(StatusViewGuard, BaseView):
"status": status.view(), "status": status.view(),
"logs": "\n".join(f"[{pretty_datetime(log_record.created)}] {log_record.message}" for log_record in logs) "logs": "\n".join(f"[{pretty_datetime(log_record.created)}] {log_record.message}" for log_record in logs)
} }
return json_response(response) return self.json_response(response)
@apidocs( @apidocs(
tags=["Packages"], tags=["Packages"],

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
@@ -105,7 +105,7 @@ class PackageView(StatusViewGuard, BaseView):
"repository": repository_id.view(), "repository": repository_id.view(),
} }
] ]
return json_response(response) return self.json_response(response)
@apidocs( @apidocs(
tags=["Packages"], tags=["Packages"],

View File

@@ -19,7 +19,7 @@
# #
import itertools import itertools
from aiohttp.web import HTTPNoContent, Response, json_response from aiohttp.web import HTTPNoContent, Response
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar from typing import ClassVar
@@ -78,7 +78,7 @@ class PackagesView(StatusViewGuard, BaseView):
} for package, status in itertools.islice(sorted(packages, key=comparator), offset, stop) } for package, status in itertools.islice(sorted(packages, key=comparator), offset, stop)
] ]
return json_response(response) return self.json_response(response)
@apidocs( @apidocs(
tags=["Packages"], tags=["Packages"],

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response from aiohttp.web import HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@@ -89,4 +89,4 @@ class PatchView(StatusViewGuard, BaseView):
if selected is None: if selected is None:
raise HTTPNotFound(reason=f"Patch {variable} is unknown") raise HTTPNotFound(reason=f"Patch {variable} is unknown")
return json_response(selected.view()) return self.json_response(selected.view())

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -60,7 +60,7 @@ class PatchesView(StatusViewGuard, BaseView):
patches = self.service().package_patches_get(package_base, None) patches = self.service().package_patches_get(package_base, None)
response = [patch.view() for patch in patches] response = [patch.view() for patch in patches]
return json_response(response) return self.json_response(response)
@apidocs( @apidocs(
tags=["Packages"], tags=["Packages"],

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, Response, json_response from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -78,4 +78,4 @@ class AddView(BaseView):
refresh=data.get("refresh", False), refresh=data.get("refresh", False),
) )
return json_response({"process_id": process_id}) return self.json_response({"process_id": process_id})

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPNoContent, Response, json_response from aiohttp.web import HTTPNoContent, Response
from typing import ClassVar from typing import ClassVar
from ahriman.core.formatters import ConfigurationPrinter from ahriman.core.formatters import ConfigurationPrinter
@@ -64,7 +64,7 @@ class ConfigView(BaseView):
for key, value in values.items() for key, value in values.items()
if key not in ConfigurationPrinter.HIDE_KEYS if key not in ConfigurationPrinter.HIDE_KEYS
] ]
return json_response(response) return self.json_response(response)
@apidocs( @apidocs(
tags=["Actions"], tags=["Actions"],

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response
from typing import ClassVar from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@@ -71,7 +71,7 @@ class PGPView(BaseView):
except Exception: except Exception:
raise HTTPNotFound(reason=f"Key {key} is unknown") raise HTTPNotFound(reason=f"Key {key} is unknown")
return json_response({"key": key}) return self.json_response({"key": key})
@apidocs( @apidocs(
tags=["Actions"], tags=["Actions"],
@@ -100,4 +100,4 @@ class PGPView(BaseView):
process_id = self.spawner.key_import(key, data.get("server")) process_id = self.spawner.key_import(key, data.get("server"))
return json_response({"process_id": process_id}) return self.json_response({"process_id": process_id})

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPNotFound, Response, json_response from aiohttp.web import HTTPNotFound, Response
from typing import ClassVar from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@@ -66,4 +66,4 @@ class ProcessView(BaseView):
"is_alive": is_alive, "is_alive": is_alive,
} }
return json_response(response) return self.json_response(response)

View File

@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, Response, json_response from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@@ -74,4 +74,4 @@ class RebuildView(BaseView):
increment=data.get("increment", True), increment=data.get("increment", True),
) )
return json_response({"process_id": process_id}) return self.json_response({"process_id": process_id})

Some files were not shown because too many files have changed in this diff Show More