refactor: 🎨 tidied a few bits up
This commit is contained in:
@@ -1,30 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { api, type AuditEntry } from "@/api";
|
||||
import { formatLocalTime } from "@/utils/datetime";
|
||||
|
||||
const logs = ref<AuditEntry[]>([]);
|
||||
const actions = ref<string[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const limit = 50;
|
||||
const offset = ref(0);
|
||||
|
||||
onMounted(async () => { logs.value = await api.audit(200); });
|
||||
const filters = ref({ user: "", action: "", from: "", to: "" });
|
||||
const applied = ref({ user: "", action: "", from: "", to: "" });
|
||||
|
||||
const exportUrl = computed(() => api.auditExportUrl({
|
||||
user: applied.value.user || undefined,
|
||||
action: applied.value.action || undefined,
|
||||
from: applied.value.from || undefined,
|
||||
to: applied.value.to || undefined,
|
||||
}));
|
||||
|
||||
const page = computed(() => Math.floor(offset.value / limit) + 1);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)));
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const d = await api.audit({
|
||||
limit,
|
||||
offset: offset.value,
|
||||
user: applied.value.user || undefined,
|
||||
action: applied.value.action || undefined,
|
||||
from: applied.value.from || undefined,
|
||||
to: applied.value.to || undefined,
|
||||
});
|
||||
logs.value = d.items;
|
||||
total.value = d.total;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to load audit log";
|
||||
logs.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
actions.value = await api.auditActions();
|
||||
} catch {
|
||||
actions.value = [];
|
||||
}
|
||||
await load();
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
applied.value = { ...filters.value };
|
||||
offset.value = 0;
|
||||
load();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters.value = { user: "", action: "", from: "", to: "" };
|
||||
applied.value = { user: "", action: "", from: "", to: "" };
|
||||
offset.value = 0;
|
||||
load();
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (offset.value >= limit) {
|
||||
offset.value -= limit;
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (offset.value + limit < total.value) {
|
||||
offset.value += limit;
|
||||
load();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold">Audit log</h1>
|
||||
<a href="/api/v2/audit/export" class="btn-secondary text-sm">Export CSV</a>
|
||||
<a :href="exportUrl" class="btn-secondary text-sm">Export CSV</a>
|
||||
</div>
|
||||
<div class="card mt-6 overflow-x-auto">
|
||||
|
||||
<form class="card mt-6 flex flex-wrap items-end gap-3" @submit.prevent="applyFilters">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-500">User</label>
|
||||
<input v-model="filters.user" class="input-field py-1.5 text-sm" placeholder="Name contains…" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-500">Action</label>
|
||||
<select v-model="filters.action" class="input-field py-1.5 text-sm">
|
||||
<option value="">All actions</option>
|
||||
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-500">From</label>
|
||||
<input v-model="filters.from" type="date" class="input-field py-1.5 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-slate-500">To</label>
|
||||
<input v-model="filters.to" type="date" class="input-field py-1.5 text-sm" />
|
||||
</div>
|
||||
<button type="submit" class="btn-primary text-sm">Apply</button>
|
||||
<button type="button" class="btn-secondary text-sm" @click="clearFilters">Clear</button>
|
||||
</form>
|
||||
|
||||
<p v-if="loading" class="mt-6 text-slate-500">Loading…</p>
|
||||
<p v-else-if="error" class="mt-6 text-red-500">{{ error }}</p>
|
||||
<div v-else class="card mt-6 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead><tr class="border-b dark:border-slate-700"><th class="p-2">Time</th><th class="p-2">User</th><th class="p-2">Action</th><th class="p-2">Details</th></tr></thead>
|
||||
<thead>
|
||||
<tr class="border-b dark:border-slate-700">
|
||||
<th class="p-2">Time</th>
|
||||
<th class="p-2">User</th>
|
||||
<th class="p-2">Action</th>
|
||||
<th class="p-2">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="l in logs" :key="l.id" class="border-b dark:border-slate-800">
|
||||
<td class="p-2 whitespace-nowrap text-xs text-slate-500">{{ formatLocalTime(l.timestamp) }}</td>
|
||||
<td class="p-2">{{ l.user_name }}</td>
|
||||
<td class="p-2">{{ l.user_name || "—" }}</td>
|
||||
<td class="p-2 font-mono text-xs">{{ l.action }}</td>
|
||||
<td class="p-2 max-w-md truncate">{{ l.details }}</td>
|
||||
</tr>
|
||||
<tr v-if="!logs.length">
|
||||
<td colspan="4" class="p-4 text-center text-slate-500">No audit entries match your filters.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && !error && total > 0" class="mt-4 flex items-center justify-between text-sm text-slate-500">
|
||||
<span>{{ total }} entries · page {{ page }} of {{ totalPages }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn-secondary text-sm" :disabled="offset === 0" @click="prevPage">Previous</button>
|
||||
<button type="button" class="btn-secondary text-sm" :disabled="offset + limit >= total" @click="nextPage">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from "vue";
|
||||
import { useRoute, useRouter, RouterLink } from "vue-router";
|
||||
import { api, type Device, type Tag, type Subnet } from "@/api";
|
||||
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
|
||||
import CustomFieldValues from "@/components/CustomFieldValues.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { formatLocalTime } from "@/utils/datetime";
|
||||
|
||||
@@ -15,7 +16,10 @@ const subnets = ref<Subnet[]>([]);
|
||||
const availableIps = ref<{ id: number; ip: string }[]>([]);
|
||||
const history = ref<IpHistoryEntry[]>([]);
|
||||
const editName = ref("");
|
||||
const editDescription = ref("");
|
||||
const saving = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const showAssignIp = ref(false);
|
||||
const assignForm = ref({ site: "", subnet_id: 0, ip_id: 0 });
|
||||
const err = ref("");
|
||||
@@ -41,20 +45,32 @@ const subnetsForSite = computed(() =>
|
||||
subnets.value.filter((s) => (s.site || "Unassigned") === assignForm.value.site),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
const id = Number(route.params.id);
|
||||
const [d, tags, h, sn] = await Promise.all([
|
||||
api.device(id),
|
||||
api.tags(),
|
||||
api.deviceIpHistory(id).catch(() => []),
|
||||
api.subnets(false),
|
||||
]);
|
||||
device.value = d;
|
||||
editName.value = d.name;
|
||||
allTags.value = tags;
|
||||
subnets.value = sn;
|
||||
history.value = h as IpHistoryEntry[];
|
||||
});
|
||||
async function loadDevice() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const id = Number(route.params.id);
|
||||
const [d, tags, h, sn] = await Promise.all([
|
||||
api.device(id),
|
||||
api.tags(),
|
||||
api.deviceIpHistory(id).catch(() => []),
|
||||
api.subnets(false),
|
||||
]);
|
||||
device.value = d;
|
||||
editName.value = d.name;
|
||||
editDescription.value = d.description || "";
|
||||
allTags.value = tags;
|
||||
subnets.value = sn;
|
||||
history.value = h as IpHistoryEntry[];
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to load device";
|
||||
device.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDevice);
|
||||
|
||||
async function loadAvailableIps(subnetId: number) {
|
||||
if (!subnetId) {
|
||||
@@ -89,12 +105,19 @@ async function openAssignIpModal() {
|
||||
showAssignIp.value = true;
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
async function saveDevice() {
|
||||
if (!device.value) return;
|
||||
saving.value = true;
|
||||
await api.updateDevice(device.value.id, { name: editName.value });
|
||||
device.value.name = editName.value;
|
||||
saving.value = false;
|
||||
err.value = "";
|
||||
try {
|
||||
await api.updateDevice(device.value.id, { name: editName.value, description: editDescription.value });
|
||||
device.value.name = editName.value;
|
||||
device.value.description = editDescription.value;
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : "Failed to save";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignTag(tagId: number) {
|
||||
@@ -115,8 +138,7 @@ async function assignIp() {
|
||||
try {
|
||||
await api.assignIp(device.value.id, assignForm.value.ip_id);
|
||||
showAssignIp.value = false;
|
||||
device.value = await api.device(device.value.id);
|
||||
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
|
||||
await loadDevice();
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : "Failed";
|
||||
}
|
||||
@@ -125,8 +147,7 @@ async function assignIp() {
|
||||
async function removeIp(ipId: number) {
|
||||
if (!device.value || !confirm("Remove this IP from the device?")) return;
|
||||
await api.removeIp(device.value.id, ipId);
|
||||
device.value = await api.device(device.value.id);
|
||||
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
|
||||
await loadDevice();
|
||||
}
|
||||
|
||||
async function deleteDevice() {
|
||||
@@ -135,86 +156,126 @@ async function deleteDevice() {
|
||||
router.push("/devices");
|
||||
}
|
||||
|
||||
function onCustomFieldsSaved(values: Record<string, unknown>) {
|
||||
if (device.value) device.value.custom_fields = values;
|
||||
}
|
||||
|
||||
function formatTime(ts?: string) {
|
||||
return formatLocalTime(ts, "Unknown");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="device">
|
||||
<div>
|
||||
<RouterLink to="/devices" class="text-sm text-accent hover:underline">← Devices</RouterLink>
|
||||
<div class="mt-4 flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<input v-if="auth.can('edit_device')" v-model="editName" class="input-field max-w-md text-xl font-bold" @blur="saveName" />
|
||||
<h1 v-else class="text-2xl font-bold">{{ device.name }}</h1>
|
||||
<p class="mt-1 text-slate-500">{{ device.description || "No description" }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="auth.can('delete_device')"
|
||||
class="text-sm text-red-500 hover:underline"
|
||||
@click="deleteDevice"
|
||||
>Delete device</button>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">IP addresses</h2>
|
||||
<button v-if="auth.can('add_device_ip')" class="text-sm text-accent hover:underline" @click="openAssignIpModal">Assign IP</button>
|
||||
<p v-if="loading" class="mt-8 text-slate-500">Loading…</p>
|
||||
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
|
||||
<template v-else-if="device">
|
||||
<div class="mt-4 flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 max-w-2xl flex-1 flex-col gap-2">
|
||||
<template v-if="auth.can('edit_device')">
|
||||
<input
|
||||
v-model="editName"
|
||||
class="input-field block w-full border-0 bg-transparent px-0 py-0 text-2xl font-bold shadow-none focus:ring-0"
|
||||
aria-label="Device name"
|
||||
@blur="saveDevice"
|
||||
/>
|
||||
<textarea
|
||||
v-model="editDescription"
|
||||
class="input-field block w-full resize-y text-sm"
|
||||
placeholder="Add a description…"
|
||||
rows="2"
|
||||
@blur="saveDevice"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h1 class="text-2xl font-bold">{{ device.name }}</h1>
|
||||
<p v-if="device.description" class="text-slate-500">{{ device.description }}</p>
|
||||
</template>
|
||||
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||
</div>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<li v-for="ip in device.ip_addresses" :key="ip.id" class="flex items-center justify-between font-mono text-sm">
|
||||
<span>{{ ip.ip }} <span class="text-slate-500">({{ ip.subnet_name }})</span></span>
|
||||
<button v-if="auth.can('remove_device_ip')" class="text-red-500 hover:underline" @click="removeIp(ip.id)">Remove</button>
|
||||
</li>
|
||||
<li v-if="!device.ip_addresses?.length" class="text-sm text-slate-500">None assigned</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="auth.can('delete_device')"
|
||||
class="shrink-0 text-sm text-red-500 hover:underline"
|
||||
@click="deleteDevice"
|
||||
>Delete device</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="font-semibold">Tags</h2>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span v-for="t in device.tags" :key="t.id" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" :style="{ backgroundColor: (t.color || '#6B7280') + '33' }">
|
||||
{{ t.name }}
|
||||
<button v-if="auth.can('assign_device_tag')" class="text-red-500 hover:underline" @click="removeTag(t.id)">×</button>
|
||||
</span>
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">IP addresses</h2>
|
||||
<button v-if="auth.can('add_device_ip')" class="text-sm text-accent hover:underline" @click="openAssignIpModal">Assign IP</button>
|
||||
</div>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<li v-for="ip in device.ip_addresses" :key="ip.id" class="flex items-center justify-between gap-3 font-mono text-sm">
|
||||
<span class="min-w-0">
|
||||
{{ ip.ip }}
|
||||
<span v-if="ip.notes || ip.subnet_name" class="font-sans text-slate-500">
|
||||
{{ ip.notes || ip.subnet_name }}
|
||||
</span>
|
||||
</span>
|
||||
<button v-if="auth.can('remove_device_ip')" class="text-red-500 hover:underline" @click="removeIp(ip.id)">Remove</button>
|
||||
</li>
|
||||
<li v-if="!device.ip_addresses?.length" class="text-sm text-slate-500">None assigned</li>
|
||||
</ul>
|
||||
</div>
|
||||
<select v-if="auth.can('assign_device_tag')" class="input-field mt-3" @change="assignTag(Number(($event.target as HTMLSelectElement).value)); ($event.target as HTMLSelectElement).value = ''">
|
||||
<option value="">Add tag…</option>
|
||||
<option v-for="t in allTags.filter((t) => !device!.tags?.some((dt) => dt.id === t.id))" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card lg:col-span-2">
|
||||
<h2 class="font-semibold">IP history</h2>
|
||||
<p v-if="!history.length" class="mt-2 text-sm text-slate-500">No assignment history.</p>
|
||||
<ul v-else class="mt-3 space-y-3">
|
||||
<li v-for="(entry, i) in history" :key="i" class="flex gap-3 text-sm">
|
||||
<span class="shrink-0 font-semibold uppercase text-xs" :class="entry.action === 'assigned' ? 'text-emerald-600' : 'text-red-500'">
|
||||
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
|
||||
<div class="card">
|
||||
<h2 class="font-semibold">Tags</h2>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span v-for="t in device.tags" :key="t.id" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" :style="{ backgroundColor: (t.color || '#6B7280') + '33' }">
|
||||
{{ t.name }}
|
||||
<button v-if="auth.can('assign_device_tag')" class="text-red-500 hover:underline" @click="removeTag(t.id)">×</button>
|
||||
</span>
|
||||
<span class="font-mono">{{ entry.ip }}</span>
|
||||
<span class="text-slate-500">· {{ entry.user_name }} · {{ formatTime(entry.timestamp) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAssignIp" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAssignIp = false">
|
||||
<form class="card w-full max-w-md space-y-3" @submit.prevent="assignIp">
|
||||
<h2 class="text-lg font-semibold">Assign IP</h2>
|
||||
<select v-if="!deviceSites.length" v-model="assignForm.site" class="input-field" @change="onSiteChange">
|
||||
<option v-for="site in assignableSites" :key="site" :value="site">{{ site }}</option>
|
||||
</select>
|
||||
<select v-model="assignForm.subnet_id" class="input-field" @change="onSubnetChange">
|
||||
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
|
||||
</select>
|
||||
<select v-model="assignForm.ip_id" class="input-field" required>
|
||||
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
|
||||
</select>
|
||||
<p v-if="assignForm.subnet_id && !availableIps.length" class="text-sm text-slate-500">No available IPs in this subnet</p>
|
||||
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn-primary" :disabled="!assignForm.ip_id">Assign</button>
|
||||
<button type="button" class="btn-secondary" @click="showAssignIp = false">Cancel</button>
|
||||
<span v-if="!device.tags?.length" class="text-sm text-slate-500">No tags</span>
|
||||
</div>
|
||||
<select v-if="auth.can('assign_device_tag')" class="input-field mt-3" @change="assignTag(Number(($event.target as HTMLSelectElement).value)); ($event.target as HTMLSelectElement).value = ''">
|
||||
<option value="">Add tag…</option>
|
||||
<option v-for="t in allTags.filter((t) => !device!.tags?.some((dt) => dt.id === t.id))" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<CustomFieldValues
|
||||
v-if="auth.can('view_custom_fields')"
|
||||
class="lg:col-span-2"
|
||||
entity-type="device"
|
||||
:entity-id="device.id"
|
||||
:values="device.custom_fields"
|
||||
:can-edit="auth.can('edit_device')"
|
||||
@saved="onCustomFieldsSaved"
|
||||
/>
|
||||
<div class="card lg:col-span-2">
|
||||
<h2 class="font-semibold">IP history</h2>
|
||||
<p v-if="!history.length" class="mt-2 text-sm text-slate-500">No assignment history.</p>
|
||||
<ul v-else class="mt-3 space-y-3">
|
||||
<li v-for="(entry, i) in history" :key="i" class="flex gap-3 text-sm">
|
||||
<span class="shrink-0 text-xs font-semibold uppercase" :class="entry.action === 'assigned' ? 'text-emerald-600' : 'text-red-500'">
|
||||
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
|
||||
</span>
|
||||
<span class="font-mono">{{ entry.ip }}</span>
|
||||
<span class="text-slate-500">· {{ entry.user_name }} · {{ formatTime(entry.timestamp) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAssignIp" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAssignIp = false">
|
||||
<form class="card w-full max-w-md space-y-3" @submit.prevent="assignIp">
|
||||
<h2 class="text-lg font-semibold">Assign IP</h2>
|
||||
<select v-if="!deviceSites.length" v-model="assignForm.site" class="input-field" @change="onSiteChange">
|
||||
<option v-for="site in assignableSites" :key="site" :value="site">{{ site }}</option>
|
||||
</select>
|
||||
<select v-model="assignForm.subnet_id" class="input-field" @change="onSubnetChange">
|
||||
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
|
||||
</select>
|
||||
<select v-model="assignForm.ip_id" class="input-field" required>
|
||||
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
|
||||
</select>
|
||||
<p v-if="assignForm.subnet_id && !availableIps.length" class="text-sm text-slate-500">No available IPs in this subnet</p>
|
||||
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn-primary" :disabled="!assignForm.ip_id">Assign</button>
|
||||
<button type="button" class="btn-secondary" @click="showAssignIp = false">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,10 +10,20 @@ const showAdd = ref(false);
|
||||
const showEdit = ref(false);
|
||||
const form = ref({ name: "", site: "", height_u: 42 });
|
||||
const editId = ref(0);
|
||||
const loading = ref(true);
|
||||
const err = ref("");
|
||||
|
||||
async function load() {
|
||||
racks.value = await api.racks();
|
||||
loading.value = true;
|
||||
err.value = "";
|
||||
try {
|
||||
racks.value = await api.racks();
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : "Failed to load racks";
|
||||
racks.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
@@ -60,7 +70,10 @@ async function del(id: number) {
|
||||
<h1 class="text-2xl font-bold">Racks</h1>
|
||||
<button v-if="auth.can('add_rack')" class="btn-primary text-sm" @click="showAdd = true; err = ''">Add rack</button>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<p v-if="loading" class="mt-6 text-slate-500">Loading…</p>
|
||||
<p v-else-if="err && !racks.length" class="mt-6 text-red-500">{{ err }}</p>
|
||||
<p v-else-if="!racks.length" class="mt-6 text-slate-500">No racks yet.</p>
|
||||
<div v-else class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="r in racks" :key="r.id" class="card">
|
||||
<RouterLink :to="`/racks/${r.id}`" class="block transition hover:text-accent">
|
||||
<div class="font-medium">{{ r.name }}</div>
|
||||
|
||||
@@ -5,77 +5,135 @@ import { api, type Subnet } from "@/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import IpHistoryModal from "@/components/IpHistoryModal.vue";
|
||||
import DhcpModal from "@/components/DhcpModal.vue";
|
||||
import CustomFieldValues from "@/components/CustomFieldValues.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const subnet = ref<Subnet | null>(null);
|
||||
const historyIp = ref<string | null>(null);
|
||||
const showDhcp = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const notesErr = ref("");
|
||||
|
||||
async function loadSubnet() {
|
||||
subnet.value = await api.subnet(Number(route.params.id));
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
subnet.value = await api.subnet(Number(route.params.id));
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to load subnet";
|
||||
subnet.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSubnet);
|
||||
|
||||
async function saveNotes(ipId: number, notes: string) {
|
||||
await api.patchIpNotes(ipId, notes);
|
||||
notesErr.value = "";
|
||||
try {
|
||||
await api.patchIpNotes(ipId, notes);
|
||||
} catch (e) {
|
||||
notesErr.value = e instanceof Error ? e.message : "Failed to save notes";
|
||||
}
|
||||
}
|
||||
|
||||
function onCustomFieldsSaved(values: Record<string, unknown>) {
|
||||
if (subnet.value) subnet.value.custom_fields = values;
|
||||
}
|
||||
|
||||
function isDhcpRow(hostname?: string) {
|
||||
return hostname === "DHCP";
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="subnet">
|
||||
<div>
|
||||
<RouterLink to="/" class="text-sm text-accent hover:underline">← Home</RouterLink>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ subnet.name }}</h1>
|
||||
<p class="font-mono text-slate-500">{{ subnet.cidr }} · {{ subnet.site || "Unassigned" }}</p>
|
||||
<p v-if="loading" class="mt-8 text-slate-500">Loading…</p>
|
||||
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
|
||||
<template v-else-if="subnet">
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ subnet.name }}</h1>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 font-mono text-slate-500">
|
||||
<span>{{ subnet.cidr }} · {{ subnet.site || "Unassigned" }}</span>
|
||||
<span
|
||||
v-if="subnet.vlan_id"
|
||||
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold font-sans text-slate-600 dark:text-slate-300"
|
||||
>VLAN {{ subnet.vlan_id }}</span>
|
||||
</div>
|
||||
<p v-if="subnet.vlan_description" class="mt-1 text-sm text-slate-500">{{ subnet.vlan_description }}</p>
|
||||
<p v-if="subnet.vlan_notes" class="text-sm text-slate-400">{{ subnet.vlan_notes }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="auth.can('view_dhcp') || auth.can('configure_dhcp')"
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
@click="showDhcp = true"
|
||||
>
|
||||
DHCP
|
||||
</button>
|
||||
<a v-if="auth.can('export_subnet_csv')" :href="`/api/v2/subnets/${subnet.id}/export`" class="btn-secondary text-sm">Export CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="auth.can('view_dhcp') || auth.can('configure_dhcp')"
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
@click="showDhcp = true"
|
||||
>
|
||||
DHCP
|
||||
</button>
|
||||
<a v-if="auth.can('export_subnet_csv')" :href="`/api/v2/subnets/${subnet.id}/export`" class="btn-secondary text-sm">Export CSV</a>
|
||||
|
||||
<CustomFieldValues
|
||||
v-if="auth.can('view_custom_fields')"
|
||||
class="mt-6"
|
||||
entity-type="subnet"
|
||||
:entity-id="subnet.id"
|
||||
:values="subnet.custom_fields"
|
||||
:can-edit="auth.can('edit_subnet')"
|
||||
@saved="onCustomFieldsSaved"
|
||||
/>
|
||||
|
||||
<div class="card mt-6 overflow-x-auto">
|
||||
<p v-if="notesErr" class="mb-2 text-sm text-red-500">{{ notesErr }}</p>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="p-2 font-medium">IP</th>
|
||||
<th class="p-2 font-medium">Hostname</th>
|
||||
<th class="p-2 font-medium">Notes</th>
|
||||
<th class="p-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="ip in subnet.ip_addresses"
|
||||
:key="ip.id"
|
||||
class="border-b border-slate-100 dark:border-slate-800"
|
||||
:class="isDhcpRow(ip.hostname) ? 'bg-amber-50/80 italic dark:bg-amber-950/20' : ''"
|
||||
>
|
||||
<td class="p-2 font-mono">{{ ip.ip }}</td>
|
||||
<td class="p-2" :class="isDhcpRow(ip.hostname) ? 'text-amber-700 dark:text-amber-400' : ''">
|
||||
<RouterLink v-if="ip.device_id" :to="`/devices/${ip.device_id}`" class="text-accent hover:underline not-italic">{{ ip.device_name || ip.hostname }}</RouterLink>
|
||||
<span v-else>{{ ip.hostname || "—" }}</span>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<input
|
||||
v-if="auth.can('edit_subnet')"
|
||||
:value="ip.notes || ''"
|
||||
class="input-field py-1 text-xs"
|
||||
@change="saveNotes(ip.id, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span v-else>{{ ip.notes || "—" }}</span>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<button type="button" class="text-xs text-accent hover:underline" @click="historyIp = ip.ip">History</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!subnet.ip_addresses?.length">
|
||||
<td colspan="4" class="p-4 text-center text-slate-500">No IP addresses in this subnet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-6 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="p-2 font-medium">IP</th>
|
||||
<th class="p-2 font-medium">Hostname</th>
|
||||
<th class="p-2 font-medium">Notes</th>
|
||||
<th class="p-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ip in subnet.ip_addresses" :key="ip.id" class="border-b border-slate-100 dark:border-slate-800">
|
||||
<td class="p-2 font-mono">{{ ip.ip }}</td>
|
||||
<td class="p-2">
|
||||
<RouterLink v-if="ip.device_id" :to="`/devices/${ip.device_id}`" class="text-accent hover:underline">{{ ip.device_name || ip.hostname }}</RouterLink>
|
||||
<span v-else>{{ ip.hostname || "—" }}</span>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<input
|
||||
v-if="auth.can('edit_subnet')"
|
||||
:value="ip.notes || ''"
|
||||
class="input-field py-1 text-xs"
|
||||
@change="saveNotes(ip.id, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span v-else>{{ ip.notes || "—" }}</span>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<button type="button" class="text-xs text-accent hover:underline" @click="historyIp = ip.ip">History</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<IpHistoryModal :ip="historyIp" @close="historyIp = null" />
|
||||
<DhcpModal :open="showDhcp" :subnet-id="subnet.id" @close="showDhcp = false" @saved="loadSubnet" />
|
||||
<IpHistoryModal :ip="historyIp" @close="historyIp = null" />
|
||||
<DhcpModal :open="showDhcp" :subnet-id="subnet.id" @close="showDhcp = false" @saved="loadSubnet" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,25 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { api, type Tag } from "@/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const tags = ref<Tag[]>([]);
|
||||
const form = ref({ name: "", color: "#06b6d4", description: "" });
|
||||
const editForm = ref({ id: 0, name: "", color: "#06b6d4", description: "" });
|
||||
const showEdit = ref(false);
|
||||
const loading = ref(true);
|
||||
const err = ref("");
|
||||
|
||||
onMounted(async () => { tags.value = await api.tags(); });
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
err.value = "";
|
||||
try {
|
||||
tags.value = await api.tags();
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : "Failed to load tags";
|
||||
tags.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function create() {
|
||||
await api.createTag(form.value);
|
||||
tags.value = await api.tags();
|
||||
form.value = { name: "", color: "#06b6d4", description: "" };
|
||||
err.value = "";
|
||||
try {
|
||||
await api.createTag(form.value);
|
||||
form.value = { name: "", color: "#06b6d4", description: "" };
|
||||
await load();
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : "Failed";
|
||||
}
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm("Delete tag?")) return;
|
||||
await api.deleteTag(id);
|
||||
tags.value = await api.tags();
|
||||
err.value = "";
|
||||
try {
|
||||
await api.deleteTag(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : "Failed";
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(t: Tag) {
|
||||
@@ -37,7 +63,7 @@ async function saveEdit() {
|
||||
description: editForm.value.description,
|
||||
});
|
||||
showEdit.value = false;
|
||||
tags.value = await api.tags();
|
||||
await load();
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : "Failed";
|
||||
}
|
||||
@@ -46,21 +72,25 @@ async function saveEdit() {
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Tags</h1>
|
||||
<form class="card mt-6 flex flex-wrap gap-3" @submit.prevent="create">
|
||||
<form v-if="auth.can('add_tag')" class="card mt-6 flex flex-wrap gap-3" @submit.prevent="create">
|
||||
<input v-model="form.name" class="input-field max-w-xs" placeholder="Name" required />
|
||||
<input v-model="form.color" type="color" class="h-10 w-14 rounded border-0" />
|
||||
<input v-model="form.description" class="input-field max-w-xs" placeholder="Description" />
|
||||
<button class="btn-primary">Add tag</button>
|
||||
</form>
|
||||
<ul class="mt-6 space-y-2">
|
||||
<p v-if="loading" class="mt-6 text-slate-500">Loading…</p>
|
||||
<p v-else-if="err && !tags.length" class="mt-6 text-red-500">{{ err }}</p>
|
||||
<p v-else-if="!tags.length" class="mt-6 text-slate-500">No tags yet.</p>
|
||||
<ul v-else class="mt-6 space-y-2">
|
||||
<li v-for="t in tags" :key="t.id" class="card flex items-center justify-between">
|
||||
<span><span class="inline-block h-3 w-3 rounded-full mr-2" :style="{ backgroundColor: t.color }" />{{ t.name }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="text-sm text-accent hover:underline" @click="openEdit(t)">Edit</button>
|
||||
<button class="text-sm text-red-500" @click="del(t.id)">Delete</button>
|
||||
<div v-if="auth.can('edit_tag') || auth.can('delete_tag')" class="flex gap-2">
|
||||
<button v-if="auth.can('edit_tag')" class="text-sm text-accent hover:underline" @click="openEdit(t)">Edit</button>
|
||||
<button v-if="auth.can('delete_tag')" class="text-sm text-red-500" @click="del(t.id)">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="err && tags.length" class="mt-4 text-sm text-red-500">{{ err }}</p>
|
||||
|
||||
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
|
||||
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveEdit">
|
||||
|
||||
Reference in New Issue
Block a user