feat: add group filter to send logs view

- Add groupId query param to GET /api/logs endpoint
- Add group filter dropdown in SendLogs component
- Pass groups list and filter state from admin.vue
- Update useLogs.ts to accept optional groupId parameter
- Add groupId to SendRecord type
- Add i18n keys and CSS for filter UI
This commit is contained in:
RhenCloud 2026-08-08 23:09:06 +08:00
parent 03eef7ae89
commit 589558a20d
7 changed files with 94 additions and 16 deletions

View file

@ -909,7 +909,38 @@ main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.log-filters {
display: flex;
align-items: center;
gap: 6px;
}
.filter-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
color: var(--muted);
text-transform: uppercase;
}
.filter-select {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
padding: 4px 8px;
cursor: pointer;
outline: none;
}
.filter-select:focus {
border-color: var(--accent);
}
.log-list {

View file

@ -2,6 +2,20 @@
<div>
<div class="log-toolbar">
<span class="kpi-label">{{ t("logs.lastSends", { n: logs.length }) }}</span>
<div class="log-filters">
<label class="filter-label">{{ t("logs.filterGroup") }}</label>
<select
class="filter-select"
:value="selectedGroupId"
:disabled="loading"
@change="onGroupFilter"
>
<option value="">{{ t("logs.allGroups") }}</option>
<option v-for="g in groups" :key="g.id" :value="g.id">
{{ g.name || g.id }}
</option>
</select>
</div>
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="refresh">
{{ t("logs.refresh") }}
</button>
@ -68,13 +82,22 @@
</template>
<script setup lang="ts">
import type { SendRecord } from "~/types";
import type { Group, SendRecord } from "~/types";
const { t } = useI18n();
const { loadById } = useSendLogs();
const props = defineProps<{ logs: SendRecord[]; loading: boolean }>();
const emit = defineEmits<{ (e: "refresh"): void }>();
const props = defineProps<{
logs: SendRecord[];
loading: boolean;
groups: Group[];
selectedGroupId: string;
}>();
const emit = defineEmits<{
(e: "refresh"): void;
(e: "filter"): void;
(e: "update:selectedGroupId", value: string): void;
}>();
const detailOpen = ref(false);
const detail = ref<SendRecord | null>(null);
@ -121,6 +144,11 @@ function refresh(): void {
emit("refresh");
}
function onGroupFilter(e: Event): void {
emit("update:selectedGroupId", (e.target as HTMLSelectElement).value);
emit("filter");
}
async function openDetail(l: SendRecord): Promise<void> {
if (l.id == null) return;
detailOpen.value = true;

View file

@ -151,6 +151,8 @@ const en: Dict = {
"logs.no": "Failed",
"logs.error": "Error",
"logs.detail": "Extra Info",
"logs.filterGroup": "Group",
"logs.allGroups": "All groups",
"toast.routesSaved": "Routes saved",
"toast.routeDeleted": "Route deleted",
"toast.groupSaved": "Groups saved",
@ -311,6 +313,8 @@ const zh: Dict = {
"logs.no": "失败",
"logs.error": "错误信息",
"logs.detail": "附加信息",
"logs.filterGroup": "分组",
"logs.allGroups": "全部分组",
"toast.routesSaved": "路由已保存",
"toast.routeDeleted": "路由已删除",
"toast.groupSaved": "分组已保存",

View file

@ -6,12 +6,14 @@ export function useSendLogs() {
const needLogin = ref(false);
const error = ref("");
async function load(limit = 50): Promise<void> {
async function load(limit = 50, groupId?: string): Promise<void> {
loading.value = true;
error.value = "";
needLogin.value = false;
try {
const res = await fetch(`/admin/api/logs?limit=${limit}`, {
let url = `/admin/api/logs?limit=${limit}`;
if (groupId) url += `&groupId=${encodeURIComponent(groupId)}`;
const res = await fetch(url, {
headers: { accept: "application/json" },
credentials: "same-origin",
});
@ -44,4 +46,4 @@ export function useSendLogs() {
}
return { logs, loading, needLogin, error, load, loadById };
}
}

View file

@ -156,7 +156,15 @@
<span>{{ logsLoading ? t("status.loading") : t("status.connected") }}</span>
</div>
</section>
<SendLogs :logs="logs" :loading="logsLoading" @refresh="loadLogs" />
<SendLogs
:logs="logs"
:loading="logsLoading"
:groups="groups"
:selected-group-id="logFilterGroup"
@refresh="loadLogs"
@filter="loadLogs"
@update:selected-group-id="logFilterGroup = $event"
/>
</template>
</template>
</template>
@ -214,19 +222,21 @@ const groupEditorOpen = ref(false);
const editingGroup = ref<Group | null>(null);
const savingGroup = ref(false);
const logFilterGroup = ref("");
onMounted(() => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
forbidden.value = params.get("error") === "forbidden";
}
loadGroups();
loadLogs();
loadLogs(50, logFilterGroup.value || undefined);
});
function switchView(next: "groups" | "logs"): void {
view.value = next;
if (next === "groups") loadGroups();
if (next === "logs") loadLogs();
if (next === "logs") loadLogs(50, logFilterGroup.value || undefined);
}
function enterGroup(group: Group): void {

View file

@ -142,6 +142,7 @@ export interface SendRecord {
id?: number;
ts: number;
routeId: string;
groupId?: string;
event: string;
repo?: string;
target: string;

View file

@ -287,13 +287,15 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100);
if (s.scope.isSuper) {
return c.json({ logs: await getSendLog(c.env.DB, limit) });
}
const logs = (await getSendLog(c.env.DB, 200))
.filter((l) => l.groupId != null && s.scope.groupIds.has(l.groupId))
.slice(0, limit);
return c.json({ logs });
const filterGroupId = c.req.query("groupId") || undefined;
const allLogs = await getSendLog(c.env.DB, 200);
const allowed = s.scope.isSuper
? allLogs
: allLogs.filter((l) => l.groupId != null && s.scope.groupIds.has(l.groupId));
const logs = filterGroupId
? allowed.filter((l) => l.groupId === filterGroupId)
: allowed;
return c.json({ logs: logs.slice(0, limit) });
});
app.get("/api/logs/:id", async (c) => {