Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/merge-deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Deploy on PR Merge

on:
pull_request:
types: [closed]
branches: [ main ]

jobs:
deploy:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest

permissions:
contents: write
packages: write

steps:
- name: Checkout app repo
uses: actions/checkout@v4

- name: Set image tag
run: |
TAG=$(git rev-parse --short HEAD)
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV

- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: lzhengqc
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build & Push Image
uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/lzhengqc/visualsubnetcalc:${{ env.IMAGE_TAG }}

- name: Checkout GitOps repo
uses: actions/checkout@v4
with:
repository: lzhengqc/visualsubnetcalc-gitops
token: ${{ secrets.GITOPS_TOKEN }}
path: gitops

- name: Update GitOps deployment
run: |
sed -i "s|image: .*|image: ghcr.io/lzhengqc/visualsubnetcalc:${IMAGE_TAG}|g" \
gitops/k8s/deployment.yaml

cd gitops
git config user.name "github-actions"
git config user.email "actions@github.com"
git commit -am "deploy: visualsubnetcalc ${IMAGE_TAG}"
git push
30 changes: 30 additions & 0 deletions .github/workflows/pr-ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: PR Build Image

on:
pull_request:
branches: [ main ]

jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write

steps:
- uses: actions/checkout@v4

- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: lzhengqc
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build & Push PR image
run: |
TAG=pr-${{ github.event.pull_request.number }}
docker build -t ghcr.io/lzhengqc/visualsubnetcalc:$TAG .
# add latest tag for testing purpose
docker tag ghcr.io/lzhengqc/visualsubnetcalc:$TAG ghcr.io/lzhengqc/visualsubnetcalc:latest
docker push ghcr.io/lzhengqc/visualsubnetcalc:latest
docker push ghcr.io/lzhengqc/visualsubnetcalc:$TAG
5 changes: 5 additions & 0 deletions dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ <h1>Visual Subnet Calculator</h1>
<div style="height:1.5rem"></div>
<div>
<button id="btn_go" class="btn btn-success mb-0 mt-auto" type="button">Go</button>
<button id="btn_save_file" class="btn btn-outline-primary mb-0 ms-2 mt-auto" type="button">Save</button>
<button id="btn_load_file" class="btn btn-outline-secondary mb-0 ms-2 mt-auto" type="button">Load</button>
<div class="dropdown d-inline">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Tools
Expand Down Expand Up @@ -179,6 +181,9 @@ <h3 class="modal-title" id="notifyModalLabel">Warning!</h3>
</div>
</div>

<!-- Hidden file input for loading configurations -->
<input type="file" id="hiddenFileInput" style="display: none;" accept=".json" aria-label="Load configuration file">

<div class="modal fade" id="importExportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content" role="alertdialog" aria-labelledby="importExportModalLabel" aria-describedby="importExportModalDescription">
Expand Down
112 changes: 110 additions & 2 deletions dist/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ $('#calcbody').on('click', '.row_address, .row_range, .row_usable, .row_hosts, .
// We could re-render here, but there is really no point, keep performant and just change the background color now
//renderTable();
$(this).closest('tr').css('background-color', inflightColor)
saveConfigToLocalStorage(exportConfig(false)); // Auto-save when color is set
}
})

Expand Down Expand Up @@ -144,6 +145,83 @@ $('#dropdown_oci').click(function() {
$('#importBtn').on('click', function() {
importConfig(JSON.parse($('#importExportArea').val()))
})
// Save configuration to a JSON file
$('#btn_save_file').on('click', function(e) {
e.preventDefault();
const config = exportConfig(false);
const jsonString = JSON.stringify(config, null, 2);

// Save to disk via API
fetch('/api/config/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: jsonString
}).catch(err => console.warn('API save failed (offline?), using local save:', err));

// Also download file to user's computer
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `subnet-config-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);

// Save to localStorage too
saveConfigToLocalStorage(config);
})

// Load configuration from a JSON file
$('#btn_load_file').on('click', function(e) {
e.preventDefault();
$('#hiddenFileInput').click();
})

$('#hiddenFileInput').on('change', function(e) {
const file = e.target.files[0];
if (!file) return;

const reader = new FileReader();
reader.onload = function(event) {
try {
const config = JSON.parse(event.target.result);
importConfig(config);
// Auto-save to localStorage after successful load
saveConfigToLocalStorage(config);
$('#input_form').removeClass('was-validated');
show_warning_modal('<div>Configuration loaded successfully!</div>');
} catch (error) {
show_warning_modal('<div>Error loading configuration file: ' + error.message + '</div>');
}
};
reader.readAsText(file);
// Reset the input so the same file can be loaded again
this.value = '';
})

// Save configuration to localStorage
function saveConfigToLocalStorage(config) {
try {
localStorage.setItem('visualSubnetCalc_config', JSON.stringify(config));
} catch (e) {
console.warn('Failed to save configuration to localStorage:', e);
}
}

// Load configuration from localStorage
function loadConfigFromLocalStorage() {
try {
const stored = localStorage.getItem('visualSubnetCalc_config');
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load configuration from localStorage:', e);
}
return null;
}

$('#bottom_nav #colors_word_open').on('click', function() {
$('#bottom_nav #color_palette').removeClass('d-none');
Expand Down Expand Up @@ -207,6 +285,8 @@ function reset() {
}
maxNetSize = parseInt($('#netsize').val())
renderTable(operatingMode);
// Auto-save configuration to localStorage
saveConfigToLocalStorage(exportConfig(false));
}

function changeBaseNetwork(newBaseNetwork) {
Expand All @@ -228,6 +308,7 @@ $('#calcbody').on('click', 'td.split,td.join', function(event) {
mutate_subnet_map(this.dataset.mutateVerb, this.dataset.subnet, '')
this.dataset.subnet = sortIPCIDRs(this.dataset.subnet)
renderTable(operatingMode);
saveConfigToLocalStorage(exportConfig(false));
})

$('#calcbody').on('keyup', 'td.note input', function(event) {
Expand All @@ -236,13 +317,15 @@ $('#calcbody').on('keyup', 'td.note input', function(event) {
clearTimeout(noteTimeout);
noteTimeout = setTimeout(function(element) {
mutate_subnet_map('note', element.dataset.subnet, '', element.value)
saveConfigToLocalStorage(exportConfig(false));
}, delay, this);
})

$('#calcbody').on('focusout', 'td.note input', function(event) {
// HTML DOM Data elements! Yay! See the `data-*` attributes of the HTML tags
clearTimeout(noteTimeout);
mutate_subnet_map('note', this.dataset.subnet, '', this.value)
saveConfigToLocalStorage(exportConfig(false));
})


Expand Down Expand Up @@ -823,7 +906,32 @@ $( document ).ready(function() {

let autoConfigResult = processConfigUrl();
if (!autoConfigResult) {
reset();
// Try to auto-load from disk via API first (persistent across browser restarts)
fetch('/api/config/load')
.then(response => response.ok ? response.json() : null)
.then(diskConfig => {
if (diskConfig) {
importConfig(diskConfig);
} else {
// Fall back to localStorage
const storedConfig = loadConfigFromLocalStorage();
if (storedConfig) {
importConfig(storedConfig);
} else {
reset();
}
}
})
.catch(err => {
console.warn('API load failed (offline?), trying localStorage:', err);
// Fall back to localStorage if API fails
const storedConfig = loadConfigFromLocalStorage();
if (storedConfig) {
importConfig(storedConfig);
} else {
reset();
}
});
}
});

Expand Down Expand Up @@ -973,7 +1081,7 @@ function importConfig(text) {
subnetMap = sortIPCIDRs(text['subnets']);
operatingMode = text['operating_mode'] || 'Standard'
switchMode(operatingMode);

renderTable(operatingMode);
}

function sortIPCIDRs(obj) {
Expand Down
54 changes: 54 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const express = require('express');
const path = require('path');
const fs = require('fs');

const app = express();
const PORT = process.env.PORT || 8080;
const CONFIG_DIR = '/app/config';
const CONFIG_FILE = path.join(CONFIG_DIR, 'last-config.json');

// Ensure config directory exists
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}

// Middleware
app.use(express.json());
app.use(express.static(path.join(__dirname, 'dist')));

// API: Save configuration to disk
app.post('/api/config/save', (req, res) => {
try {
const config = req.body;
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
res.json({ success: true, message: 'Configuration saved to disk' });
} catch (error) {
console.error('Error saving config:', error);
res.status(500).json({ success: false, error: error.message });
}
});

// API: Load configuration from disk
app.get('/api/config/load', (req, res) => {
try {
if (fs.existsSync(CONFIG_FILE)) {
const config = fs.readFileSync(CONFIG_FILE, 'utf-8');
res.json(JSON.parse(config));
} else {
res.status(404).json({ success: false, error: 'No saved configuration found' });
}
} catch (error) {
console.error('Error loading config:', error);
res.status(500).json({ success: false, error: error.message });
}
});

// Serve index.html for root path
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});

// Start server
app.listen(PORT, () => {
console.log(`Visual Subnet Calculator server running on port ${PORT}`);
});