Add docker-ip-addr-manager initial app
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user