track requesters for builds releases deployments and migrations
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-10 10:42:56 +09:00
parent ad52c48bca
commit 9625752a81
20 changed files with 1166 additions and 53 deletions
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react';
type CommitAuthorUser = {
login?: string;
username?: string;
full_name?: string;
name?: string;
avatar_url?: string;
avatarUrl?: string;
} | null | undefined;
export type CommitAuthorLike = {
commit?: {
author?: {
name?: string;
};
};
author?: CommitAuthorUser;
committer?: CommitAuthorUser;
};
export function commitAuthorName(commit: CommitAuthorLike) {
return commit.author?.login
|| commit.author?.username
|| commit.author?.full_name
|| commit.author?.name
|| commit.commit?.author?.name
|| commit.committer?.login
|| commit.committer?.username
|| commit.committer?.full_name
|| commit.committer?.name
|| '-';
}
function commitAuthorAvatarUrl(commit: CommitAuthorLike) {
return commit.author?.avatar_url
|| commit.author?.avatarUrl
|| commit.committer?.avatar_url
|| commit.committer?.avatarUrl
|| '';
}
export function CommitAuthor({ commit }: { commit: CommitAuthorLike }) {
const [failed, setFailed] = useState(false);
const name = commitAuthorName(commit);
const avatarUrl = commitAuthorAvatarUrl(commit);
const initial = name === '-' ? '?' : name.trim().charAt(0).toUpperCase() || '?';
return (
<span className="flex min-w-0 items-center gap-2">
{avatarUrl && !failed ? (
<img
src={avatarUrl}
alt=""
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
onError={() => setFailed(true)}
/>
) : (
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
{initial}
</span>
)}
<span className="truncate font-medium">{name}</span>
</span>
);
}
+68 -6
View File
@@ -6,6 +6,7 @@ export type DataTableColumn<T> = {
key: string;
header: string;
className?: string;
width?: number;
cell: (row: T) => ReactNode;
};
@@ -16,6 +17,25 @@ export type DataTableFilter<T> = {
getValue: (row: T) => string;
};
const spacingWidthPattern = /\bw-(?:\[(\d+)px\]|(\d+))\b/;
const widthClassPattern = /\bw-(?:\[[^\]]+\]|\d+)\b/g;
function getColumnWidth<T>(column: DataTableColumn<T>) {
if (column.key === 'no') return 75;
if (column.width) return column.width;
const widthMatch = column.className?.match(spacingWidthPattern);
if (widthMatch?.[1]) return Number(widthMatch[1]);
if (widthMatch?.[2]) return Number(widthMatch[2]) * 4;
if (column.key === 'actions') return 80;
return 160;
}
function stripWidthClass(className?: string) {
return className?.replace(widthClassPattern, '').replace(/\s+/g, ' ').trim();
}
export function DataTableCard<T>({
title,
description,
@@ -66,6 +86,7 @@ export function DataTableCard<T>({
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
const updateFilter = (key: string, value: string) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
@@ -116,11 +137,36 @@ export function DataTableCard<T>({
</div>
</div>
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
<table className="w-full text-left text-sm" style={{ minWidth }}>
<table className="w-full table-fixed text-left text-sm" style={{ minWidth }}>
<colgroup>
{columns.map((column, columnIndex) => (
<col
key={column.key}
style={
columnIndex === flexibleColumnIndex
? { width: '100%' }
: { width: getColumnWidth(column) }
}
/>
))}
</colgroup>
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
{columns.map((column) => (
<th key={column.key} className={column.className ?? 'px-5 py-3 font-medium'}>
{columns.map((column, columnIndex) => (
<th
key={column.key}
className={
column.key === 'no'
? 'py-3 text-center font-medium'
: columnIndex === flexibleColumnIndex
? stripWidthClass(column.className) || 'px-5 py-3 font-medium'
: column.className ?? 'px-5 py-3 font-medium'
}
style={{
...(column.key === 'no' ? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 } : columnIndex === flexibleColumnIndex ? { width: '100%' } : { width: getColumnWidth(column) }),
...(columnIndex === flexibleColumnIndex ? { paddingLeft: 12 } : {}),
}}
>
{column.header}
</th>
))}
@@ -129,9 +175,25 @@ export function DataTableCard<T>({
<tbody className="divide-y divide-border">
{!loading && pageRows.map((row) => (
<tr key={getRowKey(row)} className="hover:bg-muted/20">
{columns.map((column) => (
<td key={column.key} className="px-5 py-3">
{column.cell(row)}
{columns.map((column, columnIndex) => (
<td
key={column.key}
className={column.key === 'no' ? 'py-3 text-center font-medium' : columns[0]?.key === 'no' && columnIndex === 1 ? 'py-3 pl-3 pr-5' : 'px-5 py-3'}
style={
column.key === 'no'
? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 }
: columnIndex === flexibleColumnIndex
? { width: '100%' }
: { width: getColumnWidth(column) }
}
>
{column.key === 'no' ? (
<span className="block w-full text-center font-semibold">
{String((row as { no?: unknown }).no ?? '')}
</span>
) : (
column.cell(row)
)}
</td>
))}
</tr>
+48
View File
@@ -0,0 +1,48 @@
import { useState } from 'react';
export type RequesterLike = {
requestedBy?: string | null;
requestedByName?: string | null;
requestedByEmail?: string | null;
requestedByAvatarUrl?: string | null;
createdBy?: string | null;
createdByName?: string | null;
createdByEmail?: string | null;
createdByAvatarUrl?: string | null;
};
export function requesterName(item: RequesterLike) {
return item.requestedByName
|| item.createdByName
|| item.requestedBy
|| item.createdBy
|| item.requestedByEmail
|| item.createdByEmail
|| 'admin';
}
export function Requester({ item, label = '작업자' }: { item: RequesterLike; label?: string }) {
const [failed, setFailed] = useState(false);
const name = requesterName(item);
const avatarUrl = item.requestedByAvatarUrl || item.createdByAvatarUrl || '';
const initial = name.trim().charAt(0).toUpperCase() || '?';
return (
<span className="inline-flex min-w-0 items-center gap-2 text-muted-foreground">
<span>{label}</span>
{avatarUrl && !failed ? (
<img
src={avatarUrl}
alt=""
className="size-6 shrink-0 rounded-full border border-border bg-muted object-cover"
onError={() => setFailed(true)}
/>
) : (
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[11px] font-semibold text-muted-foreground">
{initial}
</span>
)}
<b className="truncate text-foreground">{name}</b>
</span>
);
}