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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px;
margin-bottom: 14px; 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 { .log-list {

View file

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

View file

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

View file

@ -6,12 +6,14 @@ export function useSendLogs() {
const needLogin = ref(false); const needLogin = ref(false);
const error = ref(""); const error = ref("");
async function load(limit = 50): Promise<void> { async function load(limit = 50, groupId?: string): Promise<void> {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
needLogin.value = false; needLogin.value = false;
try { 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" }, headers: { accept: "application/json" },
credentials: "same-origin", credentials: "same-origin",
}); });
@ -44,4 +46,4 @@ export function useSendLogs() {
} }
return { logs, loading, needLogin, error, load, loadById }; return { logs, loading, needLogin, error, load, loadById };
} }

View file

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

View file

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

View file

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