").attr('id', 'row-' + container.ID);
88 | tr.append($("").html(' '));
89 | tr.append($(" ").attr('id', 'name-' + container.ID));
90 | tr.append($(" ").attr('id', 'id-' + container.ID));
91 | tr.append($(" ").attr('id', 'state-' + container.ID));
92 | tr.append($(" ").attr('id', 'status-' + container.ID));
93 | tr.append($(" ").attr('id', 'image-' + container.ID));
94 | tr.append($(" ").attr('id', 'port-' + container.ID));
95 | tr.append($(" ").html(' '));
96 | // first load, all rows are new so append in order the data was sent
97 | // if its the first load, append everything
98 | // if its a newly created container, will have to prepend
99 | if (firstLoadContainerList) {
100 | containersTbody.append(tr);
101 | } else {
102 | containersTbody.prepend(tr);
103 | }
104 | }
105 |
106 | // Define the attributes to be updated
107 | const attributes = ['Names', 'ID', 'State', 'Status', 'Image', 'Ports'];
108 | attributes.forEach(attr => {
109 | // If the attribute has changed
110 | if (previousStateContainers[container.ID]?.[attr] !== container[attr]) {
111 | switch (attr) {
112 | case 'Names':
113 | $(`#name-${container.ID}`).text(container.Names[0].substring(1));
114 | break;
115 | case 'ID':
116 | $(`#id-${container.ID}`).text(container.ID.substring(0, 12));
117 | break;
118 | case 'State':
119 | $(`#state-${container.ID}`).html(`${container.State} `);
120 | break;
121 | case 'Status':
122 | $(`#status-${container.ID}`).html(`${container.Status} `);
123 | break;
124 | case 'Image':
125 | $(`#image-${container.ID}`).text(container.Image);
126 | break;
127 | case 'Ports':
128 | $(`#port-${container.ID}`).html(getPortBindings(container.Ports));
129 | break;
130 | }
131 | }
132 | });
133 |
134 | // Store the current state of the container for the next update
135 | previousStateContainers[container.ID] = container;
136 | });
137 | if (firstLoadContainerList) {
138 | firstLoadContainerList = false;
139 | }
140 |
141 | $("#containers-loading").hide();
142 | };
143 | }
144 | }
145 | initContainerListES();
146 |
147 | var messagesSource = null;
148 | function initMessageES() {
149 | if (messagesSource == null || messagesSource.readyState == 2) {
150 | messagesSource = new EventSource(`${websiteUrl}/api/streams/servermessages`);
151 | messagesSource.onerror = function (event) {
152 | if (messagesSource.readyState == 2) {
153 | // retry connection to ES
154 | setTimeout(initMessageES, 5000);
155 | }
156 | }
157 | }
158 | messagesSource.onmessage = function (event) {
159 | var data = JSON.parse(event.data);
160 | let icon;
161 | let toastHeaderBgColor;
162 | const uniqueId = 'toast' + Date.now();
163 | const timeSent = new Date(data.timeSent * 1000);
164 | const now = new Date();
165 | const diffInMilliseconds = now - timeSent;
166 | const diffInMinutes = Math.floor(diffInMilliseconds / 1000 / 60);
167 | // if data.category is success use success, error uses danger
168 | switch (data.category.toLowerCase()) {
169 | case 'success':
170 | toastHeaderBgColor = 'success';
171 | icon = ` `;
172 | break;
173 | case 'error':
174 | toastHeaderBgColor = 'danger';
175 | icon = ` `;
176 | break;
177 | }
178 | $('#toast-container').append(`
179 |
180 |
188 |
189 | ${data.text}
190 |
191 |
192 |
193 | `);
194 | // Initialize the toast
195 | $('#' + uniqueId).toast('show');
196 | };
197 | }
198 | initMessageES();
199 |
200 | var imageListSource = null;
201 | function initImageListES() {
202 | if (imageListSource == null || imageListSource.readyState == 2) {
203 | imageListSource = new EventSource(`${websiteUrl}/api/streams/imagelist`);
204 | imageListSource.onerror = function (event) {
205 | if (imageListSource.readyState == 2) {
206 | // retry connection to ES
207 | setTimeout(initImageListES, 5000);
208 | }
209 | }
210 | }
211 | // handle new messages from image list stream
212 | let previousStateImages = {};
213 | let firstLoadImageList = true;
214 | const imagesTbody = $("#images-tbody");
215 | imageListSource.onmessage = function (event) {
216 | const data = JSON.parse(event.data);
217 | // Created - timestamp
218 | // Id.split(":")[1].substring(12) - gets short id, otherwise complete hash
219 | // RepoTags[0] - name of image
220 | // Size (bytes) - convert to mb
221 | // RepoTags[0].split(":")[1] gets tag of image
222 | // Labels{} - holds compose information
223 | // keep track of containerIds in the incoming data stream
224 | // getting short id
225 | const imageIds = new Set(data.map(image => image.ID));
226 | // remove any rows with IDs not in the set
227 | imagesTbody.find('tr').each(function () {
228 | const tr = $(this);
229 | const id = tr.attr('id').substring(4); // remove 'row-' prefix
230 | if (!imageIds.has(id)) {
231 | tr.remove();
232 | }
233 | });
234 | $.each(data, function (i, image) {
235 | let tr = imagesTbody.find('#row-' + image.ID);
236 | if (!tr.length) {
237 | // If the row does not exist, create it
238 | tr = $(" ").attr('id', 'stats-row-' + container.ID);
335 | tr.append($("").attr('id', 'stats-name-' + container.ID));
336 | tr.append($(" ").attr('id', 'stats-cpu-percent-' + container.ID));
337 | tr.append($(" ").attr('id', 'stats-memory-usage-' + container.ID));
338 | tr.append($(" ").attr('id', 'stats-memory-limit-' + container.ID));
339 | tr.append($(" ").attr('id', 'stats-memory-percent-' + container.ID));
340 | // first load, all rows are new so append in order the data was sent
341 | // if its the first load, append everything
342 | // if its a newly created container, will have to prepend
343 | if (firstLoadStatsList) {
344 | statsTbody.append(tr);
345 | } else {
346 | statsTbody.prepend(tr);
347 | }
348 | }
349 | // Define the attributes to be updated
350 | const attributes = ['Name', 'CpuPercent', 'MemoryUsage', 'MemoryLimit', 'MemoryPercent'];
351 | attributes.forEach(attr => {
352 | // If the attribute has changed
353 | if (previousStateStats[container.ID]?.[attr] !== container[attr]) {
354 | switch (attr) {
355 | case 'Name':
356 | $(`#stats-name-${container.ID}`).text(container[attr]);
357 | break;
358 | case 'CpuPercent':
359 | let fixedCpuPercent = container[attr].toFixed(3);
360 | $(`#stats-cpu-percent-${container.ID}`).html(`${fixedCpuPercent} % `);
361 | break;
362 | case 'MemoryUsage':
363 | // const sizeBytes = container[attr];
364 | let displaySize = convertBytes(container[attr]);
365 | $(`#stats-memory-usage-${container.ID}`).text(displaySize);
366 | break;
367 | case 'MemoryLimit':
368 | let memLimit = convertBytes(container[attr]);
369 | $(`#stats-memory-limit-${container.ID}`).text(memLimit);
370 | break;
371 | case 'MemoryPercent':
372 | let fixedMemPercent = container[attr].toFixed(3);
373 | $(`#stats-memory-percent-${container.ID}`).html(`${fixedMemPercent} % `);
374 | break;
375 | }
376 | }
377 | });
378 | // Store the current state of the container for the next update
379 | previousStateStats[container.ID] = container;
380 |
381 | if (firstLoadStatsList) {
382 | firstLoadStatsList = false;
383 | }
384 | $("#stats-loading").hide();
385 | };
386 | }
387 | initContainerStatsES()
388 |
389 |
390 |
391 |
392 | // handle file uploading
393 | $('#upload-compose-btn').click(function (e) {
394 | // get projectName
395 | const projectName = $('#projectName').val();
396 | // get yaml contents
397 | const yamlContents = $('#yamlContents').val();
398 |
399 | // alert if fields aren't filled in
400 | if (!projectName || !yamlContents) {
401 | alert('Please fill out required fields.');
402 | return;
403 | }
404 |
405 | // disable button and show spinner
406 | $(this).addClass('disabled');
407 | $(this).find('.spinner-border').toggleClass('d-none');
408 |
409 | $.ajax({
410 | url: `${websiteUrl}/api/compose/upload`,
411 | type: 'POST',
412 | data: JSON.stringify({
413 | "projectName": projectName,
414 | "yamlContents": yamlContents,
415 | }),
416 | processData: false, // tell jQuery not to process the data
417 | contentType: false, // tell jQuery not to set contentType
418 | success: function (data) {
419 | $('#upload-compose-btn').removeClass('disabled');
420 | $('#upload-compose-btn').find('.spinner-border').toggleClass('d-none');
421 | // clear projectName input and textarea
422 | $('#projectName').val('');
423 | $('#yamlContents').val('');
424 | // hide modal
425 | $('#composeModal').modal('hide');
426 | }
427 | });
428 | });
429 |
430 | // retrieve composefiles eventsource
431 | var composeFilesSource = null;
432 | let composeFilesState = new Set();
433 | function initComposeFilesSource() {
434 | if (composeFilesSource == null || composeFilesSource.readyState == 2) {
435 | composeFilesSource = new EventSource(`${websiteUrl}/api/streams/composefiles`);
436 | composeFilesSource.onerror = function (event) {
437 | if (composeFilesSource.readyState == 2) {
438 | // retry connection to ES
439 | setTimeout(composeFilesSource, 5000);
440 | }
441 | }
442 | }
443 | composeFilesSource.onmessage = function (event) {
444 | // event.data.files -> list of file names within /composefiles directory
445 | if (event.data.trim() === "") {
446 | // data hasnt changed, recieved heartbeat from server so return
447 | return;
448 | }
449 | // if event.data['files'] doesnt have what is on the screen, remove it
450 | const data = JSON.parse(event.data);
451 | data.files.forEach(fileName => {
452 | if (!composeFilesState.has(fileName)) {
453 | composeFilesState.add(fileName);
454 | const newCard = `
455 |
456 |
459 |
460 |
461 |
462 | Loading...
463 | Up
464 |
465 |
466 |
467 | Loading...
468 | Down
469 |
470 |
471 |
472 | Loading...
473 |
474 |
475 |
476 |
`;
477 | $('#compose-files-list').append(newCard);
478 | }
479 | });
480 |
481 | // Remove cards that are not in the current files list
482 | $('.compose-file').each(function () {
483 | const fileName = this.id;
484 | if (!data.files.includes(fileName)) {
485 | $(this).remove();
486 | composeFilesState.delete(fileName);
487 | }
488 | });
489 | }
490 | }
491 | initComposeFilesSource();
492 |
493 | // Function to close EventSource connections
494 | // close() calls the call_on_close in server and unsubscribes from topic
495 | function closeEventSources() {
496 | containerListSource.close();
497 | messagesSource.close();
498 | imageListSource.close();
499 | }
500 |
501 | function convertBytes(bytes) {
502 | const sizeMb = bytes / 1048576
503 | const sizeGb = sizeMb / 1024
504 | let displaySize;
505 | if (sizeGb < 1) {
506 | displaySize = `${sizeMb.toFixed(2)} MB`;
507 | } else {
508 | displaySize = `${sizeGb.toFixed(2)} GB`;
509 | }
510 | return displaySize;
511 | }
512 |
513 | // Close connections when the page is refreshed or closed
514 | $(window).on('beforeunload', function () {
515 | closeEventSources();
516 | });
517 |
518 | // handle prune selecting check boxes
519 | $('#all-prune-check').change(function () {
520 | if (this.checked) {
521 | // Disable individual checkboxes if "All" is checked
522 | $('#individual-prune-selects input[type="checkbox"]').each(function () {
523 | $(this).prop('disabled', true);
524 | $(this).prop('checked', true);
525 | });
526 | } else {
527 | // Enable individual checkboxes if "All" is unchecked
528 | $('#individual-prune-selects input[type="checkbox"]').each(function () {
529 | $(this).prop('disabled', false);
530 | $(this).prop('checked', false);
531 | });
532 | }
533 | });
534 |
535 | // handle creating a container
536 | $('#create-container-btn').on('click', function () {
537 | // image to use/pull
538 | const image = $('#run-image').val();
539 | const tag = $('#run-tag').val() === '' ? 'latest' : $('#run-tag').val();
540 | // container name
541 | const containerName = $('#container-name').val();
542 | // ports
543 | const containerPort = $('#container-port').val();
544 | const hostPort = $('#host-port').val();
545 | const selectedProtocol = $('#protocol').val();
546 | // volumes - NOT BIND MOUNTS
547 | const volumeName = $('#volume-name').val();
548 | const volumeTarget = $('#volume-bind').val();
549 | const volumeMode = $('#volume-mode').val();
550 | // env variables
551 | let envArray = [];
552 | $("#env-container .row").each(function () {
553 | let key = $(this).find(".form-control").first().val();
554 | let value = $(this).find(".form-control").last().val();
555 | envArray.push(key + "=" + value);
556 | });
557 |
558 | let createContainerReq = {
559 | 'image': image + ':' + tag
560 | };
561 |
562 | if (containerName) {
563 | createContainerReq.containerName = containerName;
564 | }
565 |
566 | if (envArray) {
567 | createContainerReq.environment = envArray;
568 | }
569 |
570 | if (containerPort && hostPort && selectedProtocol) {
571 | const formatContainerPort = containerPort + '/' + selectedProtocol;
572 | createContainerReq.ports = {
573 | [formatContainerPort]: hostPort
574 | };
575 | }
576 |
577 | if (volumeName && volumeTarget && volumeMode) {
578 | createContainerReq.volumes = {
579 | [volumeName]: {
580 | 'bind': volumeTarget,
581 | 'mode': volumeMode
582 | }
583 | };
584 | }
585 |
586 | // show spinner and disable button
587 | $('#run-container-spinner').toggleClass('d-none');
588 | $('#create-container-btn').addClass('disabled');
589 | // ajax request
590 | $.ajax({
591 | url: `${websiteUrl}/api/containers/run`,
592 | type: 'post',
593 | contentType: 'application/json',
594 | data: JSON.stringify({
595 | 'config': createContainerReq
596 | }),
597 | success: function (res) {
598 | $('#run-container-spinner').toggleClass('d-none');
599 | $('#create-container-btn').removeClass('disabled');
600 | },
601 | error: function (res) {
602 | $('#run-container-spinner').toggleClass('d-none');
603 | $('#create-container-btn').removeClass('disabled');
604 | }
605 | })
606 | });
607 |
608 | // handle prune check box
609 | $('#btncheck1').change(function () {
610 | $('#confirm-prune').toggleClass('disabled');
611 | });
612 |
613 | // handle prune button click
614 | $('#confirm-prune').on('click', function () {
615 | // hide prune modal
616 | $("#pruneModal").modal('hide');
617 | // uncheck box for next opening of modal
618 | $("#btncheck1").prop('checked', false);
619 | $("#confirm-prune").toggleClass('disabled');
620 | // get data
621 | let checkedToPrune = []
622 | $('#individual-prune-selects input[type="checkbox"]').each(function () {
623 | if ($(this).prop('checked') == true) {
624 | checkedToPrune.push($(this).val());
625 | }
626 | });
627 | // close modal
628 | $('#pruneModal').modal('hide');
629 | // disable prune button
630 | $('#prune-btn').addClass('disabled');
631 | // hide icon
632 | $('#prune-icon').addClass('d-none');
633 | // show spinner
634 | $('#prune-spinner').toggleClass('d-none');
635 | $.ajax({
636 | url: `${websiteUrl}/api/system/prune`,
637 | type: 'POST',
638 | contentType: 'application/json',
639 | data: JSON.stringify({
640 | 'objectsToPrune': checkedToPrune
641 | }),
642 | success: function (result) {
643 | // enable prune button
644 | $('#prune-btn').removeClass('disabled');
645 | // show icon
646 | $('#prune-icon').removeClass('d-none');
647 | // hide spinner
648 | $('#prune-spinner').toggleClass('d-none');
649 | // clear checkboxes
650 | $('#individual-prune-selects input[type="checkbox"]').each(function () {
651 | $(this).prop('checked', false);
652 | $(this).prop('disabled', false);
653 | });
654 | $('#all-prune-check').prop('checked', false);
655 | },
656 | error: function (result) {
657 | console.error(result);
658 | }
659 | });
660 | })
661 |
662 | // handle select all checkbox change
663 | $('#select-all').change(function () {
664 | // select all checkboxes with class of tr-checkbox and make them selected
665 | $('.tr-container-checkbox').prop('checked', this.checked);
666 | // enable/disable buttons
667 | if (this.checked) {
668 | $('#btn-stop, #btn-kill, #btn-restart, #btn-pause, #btn-delete, #btn-resume, #btn-start').removeClass('disabled');
669 | } else {
670 | // If no checkboxes are checked, disable all buttons
671 | $('#btn-stop, #btn-kill, #btn-restart, #btn-pause, #btn-delete, #btn-resume, #btn-start').addClass('disabled');
672 | }
673 | });
674 | $('#select-all-image').change(function () {
675 | // select all checkboxes with class of tr-checkbox and make them selected
676 | $('.tr-image-checkbox').prop('checked', this.checked);
677 | // enable/disable buttons
678 | if (this.checked) {
679 | $('#delete-img-btn').removeClass('disabled');
680 | } else {
681 | // If no checkboxes are checked, disable all buttons
682 | $('#delete-img-btn').addClass('disabled');
683 | }
684 | });
685 | });
686 |
687 | // Enables container action buttons after checkbox input
688 | $(document).on('change', '.tr-container-checkbox', function () {
689 | const checkedCount = $('.tr-container-checkbox:checked').length;
690 | const buttonSelector = '#btn-stop, #btn-kill, #btn-restart, #btn-pause, #btn-delete, #btn-resume, #btn-start';
691 |
692 | $(buttonSelector).toggleClass('disabled', checkedCount === 0);
693 | });
694 |
695 | // Enables image action buttons after checkbox input
696 | $(document).on('change', '.tr-image-checkbox', function () {
697 | const checkedCount = $('.tr-image-checkbox:checked').length;
698 | const buttonSelector = '#delete-img-btn';
699 |
700 | $(buttonSelector).toggleClass('disabled', checkedCount === 0);
701 | });
--------------------------------------------------------------------------------
/fastapi/static/media/docker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/fastapi/static/media/docker.png
--------------------------------------------------------------------------------
/fastapi/static/media/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/fastapi/static/media/favicon.png
--------------------------------------------------------------------------------
/pubsub-go/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM --platform=$BUILDPLATFORM golang:latest as builder
2 |
3 | ARG TARGETARCH
4 |
5 | WORKDIR /app
6 | COPY * /app/
7 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -a -o output/main main.go
8 |
9 | FROM alpine:latest
10 | WORKDIR /root
11 | COPY --from=builder /app/output/main .
12 | CMD ["./main"]
13 |
--------------------------------------------------------------------------------
/pubsub-go/go.mod:
--------------------------------------------------------------------------------
1 | module pubsub
2 |
3 | go 1.21.3
4 |
5 | require (
6 | github.com/Microsoft/go-winio v0.4.14 // indirect
7 | github.com/cespare/xxhash/v2 v2.1.2 // indirect
8 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
9 | github.com/distribution/reference v0.5.0 // indirect
10 | github.com/docker/docker v26.0.0+incompatible // indirect
11 | github.com/docker/go-connections v0.5.0 // indirect
12 | github.com/docker/go-units v0.5.0 // indirect
13 | github.com/felixge/httpsnoop v1.0.4 // indirect
14 | github.com/go-logr/logr v1.4.1 // indirect
15 | github.com/go-logr/stdr v1.2.2 // indirect
16 | github.com/go-redis/redis v6.15.9+incompatible // indirect
17 | github.com/go-redis/redis/v8 v8.11.5 // indirect
18 | github.com/gogo/protobuf v1.3.2 // indirect
19 | github.com/moby/docker-image-spec v1.3.1 // indirect
20 | github.com/opencontainers/go-digest v1.0.0 // indirect
21 | github.com/opencontainers/image-spec v1.1.0 // indirect
22 | github.com/pkg/errors v0.9.1 // indirect
23 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
24 | go.opentelemetry.io/otel v1.24.0 // indirect
25 | go.opentelemetry.io/otel/metric v1.24.0 // indirect
26 | go.opentelemetry.io/otel/trace v1.24.0 // indirect
27 | golang.org/x/sys v0.1.0 // indirect
28 | )
29 |
--------------------------------------------------------------------------------
/pubsub-go/go.sum:
--------------------------------------------------------------------------------
1 | github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
2 | github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
3 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
4 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
7 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
8 | github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
9 | github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
10 | github.com/docker/docker v26.0.0+incompatible h1:Ng2qi+gdKADUa/VM+6b6YaY2nlZhk/lVJiKR/2bMudU=
11 | github.com/docker/docker v26.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
12 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
13 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
14 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
15 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
16 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
17 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
18 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
19 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
20 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
21 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
22 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
23 | github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
24 | github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
25 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
26 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
27 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
28 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
29 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
30 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
31 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
32 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
33 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
34 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
35 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
36 | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
37 | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
38 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
39 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
40 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
42 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
43 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
44 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
45 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
46 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
47 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
48 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
49 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
50 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
51 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
52 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
53 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
54 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
55 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
56 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
57 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
58 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
59 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
60 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
61 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
62 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
63 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
64 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
65 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
66 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
67 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
68 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
69 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
70 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
71 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
72 | golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
73 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
74 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
75 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
76 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
77 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
78 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
79 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
80 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
81 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
82 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
83 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
84 |
--------------------------------------------------------------------------------
/pubsub-go/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "io"
7 | "log"
8 | "strings"
9 | "time"
10 |
11 | "github.com/docker/docker/api/types"
12 | "github.com/docker/docker/api/types/container"
13 | "github.com/docker/docker/api/types/image"
14 | "github.com/docker/docker/client"
15 | "github.com/go-redis/redis"
16 | )
17 |
18 | type ContainerInfo struct {
19 | ID string
20 | Image string
21 | Status string
22 | State string
23 | Names []string
24 | Ports []types.Port
25 | }
26 |
27 | type ImageInfo struct {
28 | ID string
29 | Name string
30 | Tag string
31 | Created int64
32 | Size int64
33 | NumContainers uint8
34 | }
35 |
36 | type ContainerStats struct {
37 | ID string
38 | Name string
39 | CpuPercent float64
40 | MemoryUsage uint64
41 | MemoryLimit uint64
42 | MemoryPercent float64
43 | }
44 |
45 | type DeletionMessage struct {
46 | ID string
47 | Message string
48 | }
49 |
50 | // Package level variables accessible by all functions
51 | // Good to keep up here if the variable doesnt need to be modified
52 | var ctx = context.Background()
53 | var redisClient = redis.NewClient(&redis.Options{
54 | Addr: "host.docker.internal:6379",
55 | Password: "", // no password set
56 | DB: 0, // use default DB
57 | })
58 |
59 | func getRunningContainersPerImage(dockerClient *client.Client) (map[string]int, error) {
60 | containers, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
61 | if err != nil {
62 | return nil, err
63 | }
64 |
65 | containerCountPerImage := make(map[string]int)
66 | for _, container := range containers {
67 | containerCountPerImage[container.ImageID]++
68 | }
69 |
70 | return containerCountPerImage, nil
71 | }
72 |
73 | func publishHomepageData(dockerClient *client.Client) {
74 | for {
75 | containers, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true})
76 | if err != nil {
77 | log.Printf("Failed to list containers: %v\n", err)
78 | continue
79 | }
80 |
81 | var containerInfos []ContainerInfo
82 | for _, container := range containers {
83 | containerInfo := ContainerInfo{
84 | ID: container.ID,
85 | Image: container.Image,
86 | Status: container.Status,
87 | State: container.State,
88 | Names: container.Names,
89 | Ports: container.Ports,
90 | }
91 | containerInfos = append(containerInfos, containerInfo)
92 | }
93 |
94 | containersJSON, err := json.Marshal(containerInfos)
95 | if err != nil {
96 | log.Printf("Failed to marshal containers to JSON: %v\n", err)
97 | continue
98 | }
99 |
100 | err = redisClient.Publish("containers_list", containersJSON).Err()
101 | if err != nil {
102 | log.Printf("Failed to publish containers list to Redis: %v\n", err)
103 | continue
104 | }
105 |
106 | time.Sleep(time.Second)
107 | }
108 | }
109 |
110 | func publishImagesList(dockerClient *client.Client) {
111 | for {
112 | images, err := dockerClient.ImageList(ctx, image.ListOptions{All: true})
113 | if err != nil {
114 | log.Printf("Failed to list images: %v\n", err)
115 | }
116 | containerCountPerImage, err := getRunningContainersPerImage(dockerClient)
117 | if err != nil {
118 | log.Printf("Failed to get running containers per image %v\n", err)
119 | }
120 | var imageInfos []ImageInfo
121 | for _, image := range images {
122 | // get number of containers running per image
123 | tagValue := "none"
124 | imageName := "none"
125 | if len(image.RepoTags) > 0 {
126 | // set image Name
127 | imageName = image.RepoTags[0]
128 | // get image tag
129 | split := strings.Split(imageName, ":")
130 | if len(split) >= 2 {
131 | tagValue = split[1]
132 | }
133 | }
134 | // get number of containers running for this image
135 | val, ok := containerCountPerImage[image.ID]
136 | var containerCount uint8
137 | if ok {
138 | containerCount = uint8(val)
139 | } else {
140 | containerCount = 0
141 | }
142 | imageInfo := ImageInfo{
143 | ID: strings.Split(image.ID, ":")[1], // remove sha: from id
144 | Name: imageName,
145 | Tag: tagValue,
146 | Created: image.Created,
147 | Size: image.Size,
148 | NumContainers: containerCount,
149 | }
150 | imageInfos = append(imageInfos, imageInfo)
151 | }
152 | imagesJSON, err := json.Marshal(imageInfos)
153 | if err != nil {
154 | log.Printf("Failed to marshal images to JSON: %v\n", err)
155 | }
156 | err = redisClient.Publish("images_list", imagesJSON).Err()
157 | if err != nil {
158 | log.Printf("Failed to publish image list to Redis: %v\n", err)
159 | }
160 | time.Sleep(time.Second)
161 | }
162 | }
163 |
164 | // chan<- means sending
165 | func collectContainerStats(ctx context.Context, dockerClient *client.Client, container types.Container, statsCh chan<- ContainerStats) {
166 | stream := true
167 | stats, err := dockerClient.ContainerStats(ctx, container.ID, stream)
168 | if err != nil {
169 | log.Printf("Error getting stats for container %s: %v", container.ID, err)
170 | return
171 | }
172 | defer stats.Body.Close()
173 |
174 | for {
175 | select {
176 | case <-ctx.Done():
177 | // Context has been cancelled, stop collecting stats
178 | return
179 | default:
180 | var containerStats types.StatsJSON
181 | if err := json.NewDecoder(stats.Body).Decode(&containerStats); err != nil {
182 | if err == io.EOF {
183 | // Stream is closed, stop collecting stats
184 | log.Printf("Stats stream for container %s closed, stopping stats collection", container.ID)
185 | return
186 | }
187 | log.Printf("Error decoding stats for container %s: %v", container.ID, err)
188 | return
189 | }
190 | var customStats ContainerStats
191 | customStats.ID = containerStats.ID
192 | customStats.Name = containerStats.Name
193 | if containerStats.CPUStats.CPUUsage.TotalUsage == 0 || containerStats.CPUStats.SystemUsage == 0 {
194 | customStats.CpuPercent = 0
195 | } else {
196 | customStats.CpuPercent = float64(containerStats.CPUStats.CPUUsage.TotalUsage) / float64(containerStats.CPUStats.SystemUsage) * 100
197 | }
198 | if containerStats.MemoryStats.Usage == 0 {
199 | customStats.MemoryPercent = 0
200 | } else {
201 | customStats.MemoryPercent = float64(containerStats.MemoryStats.Usage) / float64(containerStats.MemoryStats.Limit) * 100
202 | }
203 | customStats.MemoryUsage = containerStats.MemoryStats.Usage
204 | customStats.MemoryLimit = containerStats.MemoryStats.Limit
205 |
206 | statsCh <- customStats
207 | }
208 | }
209 | }
210 |
211 | func monitorContainers(dockerClient *client.Client, statsCh chan<- ContainerStats) {
212 | containerContexts := make(map[string]context.CancelFunc)
213 |
214 | for {
215 | // get containers
216 | containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
217 | if err != nil {
218 | log.Printf("Failed getting list of containers %v\n", err)
219 | return
220 | }
221 |
222 | // Create a set of container IDs
223 | containerIDSet := make(map[string]struct{})
224 | for _, container := range containers {
225 | containerIDSet[container.ID] = struct{}{}
226 | }
227 |
228 | for _, container := range containers {
229 | if _, exists := containerContexts[container.ID]; !exists {
230 | // New container, start a goroutine to collect its stats
231 | ctx, cancel := context.WithCancel(context.Background())
232 | containerContexts[container.ID] = cancel
233 | go collectContainerStats(ctx, dockerClient, container, statsCh)
234 | }
235 | }
236 |
237 | // Check for deleted containers
238 | for id, cancel := range containerContexts {
239 | if _, found := containerIDSet[id]; !found {
240 | // Container has been deleted, cancel its context
241 | log.Printf("Canceling context for %s", id)
242 | cancel()
243 | delete(containerContexts, id)
244 | // publish message with id and cancelled, used to delete rows of container stats for those deleted
245 | msg := DeletionMessage{
246 | ID: id,
247 | Message: "deleted",
248 | }
249 | msgJSON, err := json.Marshal(msg)
250 | if err != nil {
251 | log.Printf("Failed to marshal container stats to JSON: %v", err)
252 | return
253 | }
254 | err = redisClient.Publish("container_metrics", msgJSON).Err()
255 | if err != nil {
256 | log.Printf("Error publishing container stats to reids: %v", err)
257 | }
258 | }
259 | }
260 |
261 | time.Sleep(time.Second)
262 | }
263 | }
264 |
265 | // <-chan means recieving
266 | func publishContainerStats(redisClient *redis.Client, statsCh <-chan ContainerStats) {
267 | for stats := range statsCh {
268 | statsJSON, err := json.Marshal(stats)
269 | if err != nil {
270 | log.Printf("Failed to marshal container stats to JSON: %v", err)
271 | return
272 | }
273 |
274 | err = redisClient.Publish("container_metrics", statsJSON).Err()
275 | if err != nil {
276 | log.Printf("Error publishing container stats to reids: %v", err)
277 | }
278 | }
279 | }
280 |
281 | func main() {
282 | dockerClient, err := client.NewClientWithOpts(client.WithVersion("1.44"))
283 | if err != nil {
284 | log.Fatalf("Failed to create Docker client: %v\n", err)
285 | }
286 | defer dockerClient.Close()
287 |
288 | statsChan := make(chan ContainerStats)
289 | go publishContainerStats(redisClient, statsChan)
290 | go monitorContainers(dockerClient, statsChan)
291 | go publishHomepageData(dockerClient)
292 | go publishImagesList(dockerClient)
293 |
294 | // blocking operation, puts main() goroutine in an idle state waiting for all other goroutines to finish
295 | select {}
296 |
297 | }
298 |
--------------------------------------------------------------------------------
/resources/advanced-container.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/advanced-container.png
--------------------------------------------------------------------------------
/resources/create-container.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/create-container.png
--------------------------------------------------------------------------------
/resources/cropped/advanced-container.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/advanced-container.png
--------------------------------------------------------------------------------
/resources/cropped/create-container.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/create-container.png
--------------------------------------------------------------------------------
/resources/cropped/main-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/main-page.png
--------------------------------------------------------------------------------
/resources/cropped/main-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/main-small.png
--------------------------------------------------------------------------------
/resources/cropped/second-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/second-page.png
--------------------------------------------------------------------------------
/resources/cropped/upload-compose.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/upload-compose.png
--------------------------------------------------------------------------------
/resources/main-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/main-page.png
--------------------------------------------------------------------------------
/resources/second-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/second-page.png
--------------------------------------------------------------------------------
/resources/styled/advanced-container.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/advanced-container.png
--------------------------------------------------------------------------------
/resources/styled/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/architecture.png
--------------------------------------------------------------------------------
/resources/styled/create-container.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/create-container.png
--------------------------------------------------------------------------------
/resources/styled/goroutes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/goroutes.png
--------------------------------------------------------------------------------
/resources/styled/main-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/main-page.png
--------------------------------------------------------------------------------
/resources/styled/secondary-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/secondary-page.png
--------------------------------------------------------------------------------
/resources/styled/upload-compose.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/upload-compose.png
--------------------------------------------------------------------------------
/resources/upload-compose.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/upload-compose.png
--------------------------------------------------------------------------------