Add docker-ip-addr-manager initial app

This commit is contained in:
Joachim Friberg
2026-03-18 21:43:59 +01:00
parent 2fddde0129
commit 69011271fc
19 changed files with 1920 additions and 0 deletions
@@ -0,0 +1,216 @@
const { createApp } = Vue;
function mapEntry(entry) {
return {
...entry,
draft: {
name: entry.name,
ip: entry.ip,
cidr: entry.cidr,
device: entry.device,
},
};
}
createApp({
data() {
return {
interfaces: [],
entries: [],
usageKnown: true,
usageError: "",
loading: false,
saving: false,
busyById: {},
errorMessage: "",
form: {
name: "",
ip: "",
cidr: 16,
device: "",
},
sort: {
key: "name",
direction: "asc",
},
};
},
computed: {
sortedEntries() {
const list = [...this.entries];
const key = this.sort.key;
const multiplier = this.sort.direction === "asc" ? 1 : -1;
const extractors = {
name: (entry) => entry.name.toLowerCase(),
ip: (entry) => entry.ip,
cidr: (entry) => entry.cidr,
used: (entry) => (entry.usage_known ? (entry.used ? 1 : 0) : -1),
containers: (entry) => entry.containers.join(",").toLowerCase(),
device: (entry) => entry.device.toLowerCase(),
enabled: (entry) => (entry.enabled ? 1 : 0),
};
const extract = extractors[key] || extractors.name;
list.sort((a, b) => {
const left = extract(a);
const right = extract(b);
if (left < right) {
return -1 * multiplier;
}
if (left > right) {
return 1 * multiplier;
}
return a.name.localeCompare(b.name) * multiplier;
});
return list;
},
},
methods: {
async api(path, options = {}) {
const response = await fetch(path, {
headers: {
"Content-Type": "application/json",
},
...options,
});
let payload = {};
try {
payload = await response.json();
} catch (err) {
payload = {};
}
if (!response.ok) {
const message = payload.detail || `Request failed: ${response.status}`;
throw new Error(message);
}
return payload;
},
async loadInterfaces() {
const data = await this.api("/api/interfaces");
this.interfaces = data.items || [];
if (!this.form.device && this.interfaces.length > 0) {
this.form.device = this.interfaces[0];
}
},
async refreshEntries() {
this.loading = true;
this.errorMessage = "";
try {
const data = await this.api("/api/refresh", { method: "POST" });
this.entries = (data.items || []).map(mapEntry);
this.usageKnown = Boolean(data.usage_known);
this.usageError = data.usage_error || "";
} catch (err) {
this.errorMessage = err.message;
} finally {
this.loading = false;
}
},
async createEntry() {
this.saving = true;
this.errorMessage = "";
try {
await this.api("/api/entries", {
method: "POST",
body: JSON.stringify({
name: this.form.name,
ip: this.form.ip,
cidr: this.form.cidr,
device: this.form.device,
}),
});
this.form.name = "";
this.form.ip = "";
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
} finally {
this.saving = false;
}
},
async saveEntry(entry) {
this.errorMessage = "";
this.busyById = { ...this.busyById, [entry.id]: true };
try {
await this.api(`/api/entries/${entry.id}`, {
method: "PUT",
body: JSON.stringify({
name: entry.draft.name,
ip: entry.draft.ip,
cidr: entry.draft.cidr,
device: entry.draft.device,
}),
});
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
} finally {
this.busyById = { ...this.busyById, [entry.id]: false };
}
},
async toggleEntry(entry) {
this.errorMessage = "";
this.busyById = { ...this.busyById, [entry.id]: true };
try {
const action = entry.enabled ? "disable" : "enable";
await this.api(`/api/entries/${entry.id}/${action}`, { method: "POST" });
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
} finally {
this.busyById = { ...this.busyById, [entry.id]: false };
}
},
async deleteEntry(entry) {
this.errorMessage = "";
this.busyById = { ...this.busyById, [entry.id]: true };
try {
await this.api(`/api/entries/${entry.id}`, { method: "DELETE" });
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
} finally {
this.busyById = { ...this.busyById, [entry.id]: false };
}
},
containerLabel(entry) {
if (!entry.containers || entry.containers.length === 0) {
return "-";
}
if (entry.containers.length === 1) {
return entry.containers[0];
}
return `${entry.containers.join(", ")} (${entry.containers.length})`;
},
setSort(key) {
if (this.sort.key === key) {
this.sort.direction = this.sort.direction === "asc" ? "desc" : "asc";
return;
}
this.sort.key = key;
this.sort.direction = "asc";
},
sortIndicator(key) {
if (this.sort.key !== key) {
return "";
}
return this.sort.direction === "asc" ? "▲" : "▼";
},
isBusy(entryId) {
return Boolean(this.busyById[entryId]);
},
},
async mounted() {
try {
await this.loadInterfaces();
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
}
},
}).mount("#app");
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Docker IP Addr Manager</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<main id="app" class="app-shell" v-cloak>
<header class="topbar">
<div>
<h1>Docker IP Addr Manager</h1>
<p>Manage host IP addresses used for ZimaOS port bindings.</p>
</div>
<button type="button" class="btn btn-secondary" @click="refreshEntries" :disabled="loading">
{{ loading ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
<section class="panel">
<h2>Add IP Entry</h2>
<form class="entry-form" @submit.prevent="createEntry">
<label>
Name
<input v-model.trim="form.name" type="text" required maxlength="96" />
</label>
<label>
IP Address
<input v-model.trim="form.ip" type="text" placeholder="10.0.4.2" required />
</label>
<label>
CIDR
<input v-model.number="form.cidr" type="number" min="0" max="32" required />
</label>
<label>
Device
<select v-model="form.device" required>
<option disabled value="">Choose device</option>
<option v-for="iface in interfaces" :key="iface" :value="iface">{{ iface }}</option>
</select>
</label>
<button class="btn btn-primary" type="submit" :disabled="saving">Add</button>
</form>
</section>
<section class="panel">
<div class="status" v-if="errorMessage">{{ errorMessage }}</div>
<div class="status warning" v-if="!usageKnown">
Docker usage status is unknown. Disable/Delete operations are fail-closed until refresh succeeds.
<span v-if="usageError">Error: {{ usageError }}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th @click="setSort('name')">Name <span>{{ sortIndicator('name') }}</span></th>
<th @click="setSort('ip')">IP Address <span>{{ sortIndicator('ip') }}</span></th>
<th @click="setSort('cidr')">CIDR <span>{{ sortIndicator('cidr') }}</span></th>
<th @click="setSort('used')">Used <span>{{ sortIndicator('used') }}</span></th>
<th @click="setSort('containers')">Container(s) <span>{{ sortIndicator('containers') }}</span></th>
<th @click="setSort('device')">Device <span>{{ sortIndicator('device') }}</span></th>
<th @click="setSort('enabled')">Enabled <span>{{ sortIndicator('enabled') }}</span></th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in sortedEntries" :key="entry.id">
<td><input v-model.trim="entry.draft.name" type="text" maxlength="96" :disabled="entry.enabled" /></td>
<td><input v-model.trim="entry.draft.ip" type="text" :disabled="entry.enabled" /></td>
<td><input v-model.number="entry.draft.cidr" type="number" min="0" max="32" :disabled="entry.enabled" /></td>
<td class="status-cell">
<span v-if="!entry.usage_known" class="chip unknown">Unknown</span>
<span v-else-if="entry.used" class="chip used">Yes</span>
<span v-else class="chip free">No</span>
</td>
<td class="containers">{{ containerLabel(entry) }}</td>
<td>
<select v-model="entry.draft.device" :disabled="entry.enabled">
<option v-for="iface in interfaces" :key="`${entry.id}-${iface}`" :value="iface">{{ iface }}</option>
</select>
</td>
<td class="toggle-cell">
<button
type="button"
class="btn"
:class="entry.enabled ? 'btn-danger' : 'btn-primary'"
@click="toggleEntry(entry)"
:disabled="isBusy(entry.id)"
>
{{ entry.enabled ? 'Disable' : 'Enable' }}
</button>
</td>
<td class="actions">
<button
type="button"
class="btn btn-secondary"
@click="saveEntry(entry)"
:disabled="entry.enabled || isBusy(entry.id)"
>Save</button>
<button
type="button"
class="btn btn-danger"
@click="deleteEntry(entry)"
:disabled="entry.enabled || isBusy(entry.id)"
>Delete</button>
</td>
</tr>
<tr v-if="sortedEntries.length === 0">
<td colspan="8" class="empty">No entries configured.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
@@ -0,0 +1,227 @@
:root {
--bg: #f2f5f8;
--panel: #ffffff;
--border: #d7dee7;
--text: #1c2733;
--muted: #5f6b77;
--primary: #2274e5;
--danger: #c63434;
--warning: #8a6b00;
--chip-used: #ffe8e8;
--chip-free: #e7f7ec;
--chip-unknown: #fff6d8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Noto Sans", "Segoe UI", sans-serif;
background: linear-gradient(180deg, #f4f7fb 0%, #ecf1f7 100%);
color: var(--text);
}
[v-cloak] {
display: none;
}
.app-shell {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
gap: 1rem;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.topbar h1 {
margin: 0;
font-size: 1.45rem;
}
.topbar p {
margin: 0.2rem 0 0;
color: var(--muted);
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
}
.panel h2 {
margin: 0 0 0.8rem;
font-size: 1.05rem;
}
.entry-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
align-items: end;
}
.entry-form label {
display: grid;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
}
input,
select,
button {
font: inherit;
}
input,
select {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.55rem;
min-height: 2.2rem;
background: #fff;
color: var(--text);
}
.btn {
border: 0;
border-radius: 8px;
padding: 0.55rem 0.85rem;
cursor: pointer;
min-height: 2.2rem;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-secondary {
background: #e8edf5;
color: var(--text);
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.status {
margin-bottom: 0.8rem;
padding: 0.65rem 0.8rem;
border-radius: 8px;
background: #e8f0fc;
border: 1px solid #c7daf8;
color: #2a4b7d;
}
.status.warning {
background: #fff8df;
border-color: #f0db97;
color: var(--warning);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 900px;
}
th,
td {
border-bottom: 1px solid var(--border);
padding: 0.55rem;
text-align: left;
vertical-align: middle;
}
th {
color: var(--muted);
font-weight: 600;
font-size: 0.88rem;
user-select: none;
cursor: pointer;
white-space: nowrap;
}
th:last-child {
cursor: default;
}
.status-cell,
.toggle-cell,
.actions {
white-space: nowrap;
}
.actions {
display: flex;
gap: 0.45rem;
}
.containers {
color: var(--muted);
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
}
.chip {
display: inline-block;
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
font-weight: 600;
}
.chip.used {
background: var(--chip-used);
color: #8a1f1f;
}
.chip.free {
background: var(--chip-free);
color: #145a2c;
}
.chip.unknown {
background: var(--chip-unknown);
color: #7d5b04;
}
.empty {
text-align: center;
color: var(--muted);
padding: 1rem;
}
@media (max-width: 760px) {
.app-shell {
padding: 1rem;
}
.topbar {
flex-direction: column;
align-items: stretch;
}
}