my-durable 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { Check, X } from "lucide-vue-next";
|
|
4
|
+
import type { ModelInfo } from "@sdk/api";
|
|
5
|
+
import {
|
|
6
|
+
agentHandle,
|
|
7
|
+
agentState,
|
|
8
|
+
attempt,
|
|
9
|
+
device,
|
|
10
|
+
icon,
|
|
11
|
+
models,
|
|
12
|
+
notify,
|
|
13
|
+
number,
|
|
14
|
+
refreshDetail,
|
|
15
|
+
} from "../client/store";
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{ close: [] }>();
|
|
18
|
+
const search = ref("");
|
|
19
|
+
const filtered = computed(() =>
|
|
20
|
+
models.value.filter((m) =>
|
|
21
|
+
`${m.name} ${m.id} ${m.tier}`.toLowerCase().includes(search.value.toLowerCase()),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
const isCurrent = (m: ModelInfo) =>
|
|
25
|
+
m.id === agentState.value.agent.model?.modelId &&
|
|
26
|
+
m.provider === agentState.value.agent.model?.provider;
|
|
27
|
+
|
|
28
|
+
async function choose(m: ModelInfo) {
|
|
29
|
+
const done = await attempt(() =>
|
|
30
|
+
agentHandle.value!.configure({ model: { provider: m.provider, modelId: m.id } }),
|
|
31
|
+
);
|
|
32
|
+
if (done) {
|
|
33
|
+
await refreshDetail();
|
|
34
|
+
notify("Model saved. Used on the next turn.");
|
|
35
|
+
emit("close");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div class="modal-backdrop" @click.self="emit('close')">
|
|
42
|
+
<section class="modal model-modal">
|
|
43
|
+
<header>
|
|
44
|
+
<div>
|
|
45
|
+
<span class="eyebrow">PER AGENT · {{ device?.id }}</span>
|
|
46
|
+
<h2>Choose a model</h2>
|
|
47
|
+
</div>
|
|
48
|
+
<button class="icon-button" aria-label="Close model picker" @click="emit('close')">
|
|
49
|
+
<X :size="18" />
|
|
50
|
+
</button>
|
|
51
|
+
</header>
|
|
52
|
+
<input v-model="search" placeholder="Search models or tiers…" aria-label="Search models" />
|
|
53
|
+
<div class="model-list">
|
|
54
|
+
<button
|
|
55
|
+
v-for="m in filtered"
|
|
56
|
+
:key="`${m.provider}/${m.id}`"
|
|
57
|
+
:class="{ selected: isCurrent(m) }"
|
|
58
|
+
@click="choose(m)"
|
|
59
|
+
>
|
|
60
|
+
<img :src="icon(m.id)" alt="" />
|
|
61
|
+
<div>
|
|
62
|
+
<strong>{{ m.name }}</strong
|
|
63
|
+
><small>{{ m.tier }} / {{ m.provider }} / {{ number(m.contextWindow) }} context</small>
|
|
64
|
+
</div>
|
|
65
|
+
<Check v-if="isCurrent(m)" :size="18" />
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
<p class="hint">Models available on {{ device?.id }}. Provider keys stay on the device.</p>
|
|
69
|
+
</section>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, ref } from "vue";
|
|
3
|
+
import { Link2, Network, PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
|
|
4
|
+
import DeviceIcon from "./DeviceIcon.vue";
|
|
5
|
+
import type { TreeNode } from "@sdk/models";
|
|
6
|
+
import TreeRow from "./TreeRow.vue";
|
|
7
|
+
import { deviceClient, hub } from "../client/hub";
|
|
8
|
+
import {
|
|
9
|
+
attempt,
|
|
10
|
+
expanded,
|
|
11
|
+
layout,
|
|
12
|
+
network,
|
|
13
|
+
notify,
|
|
14
|
+
select,
|
|
15
|
+
selected,
|
|
16
|
+
toggle,
|
|
17
|
+
ui,
|
|
18
|
+
panelKeys,
|
|
19
|
+
} from "../client/store";
|
|
20
|
+
|
|
21
|
+
interface Row {
|
|
22
|
+
node: TreeNode;
|
|
23
|
+
depth: number;
|
|
24
|
+
parent?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rows = computed(() => {
|
|
28
|
+
const out: Row[] = [];
|
|
29
|
+
const walk = (nodes: TreeNode[], depth: number, parent?: string) => {
|
|
30
|
+
for (const node of nodes) {
|
|
31
|
+
out.push({ node, depth, parent });
|
|
32
|
+
if (node.children.length && expanded.value.has(node.key))
|
|
33
|
+
walk(node.children, depth + 1, node.key);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
walk(network.tree.value, 0);
|
|
37
|
+
return out;
|
|
38
|
+
});
|
|
39
|
+
const focusKey = ref<string>();
|
|
40
|
+
const list = ref<HTMLElement>();
|
|
41
|
+
|
|
42
|
+
const deviceOf = (node: TreeNode) =>
|
|
43
|
+
network.devices.value.find(
|
|
44
|
+
(d) => d.id === (node.type === "device" ? node.device.id : node.deviceId),
|
|
45
|
+
);
|
|
46
|
+
const isOnline = (node: TreeNode) => deviceOf(node)?.status === "online";
|
|
47
|
+
const isSelected = (node: TreeNode) =>
|
|
48
|
+
node.type === "agent" &&
|
|
49
|
+
selected.value?.deviceId === node.deviceId &&
|
|
50
|
+
selected.value.sessionId === node.sessionId &&
|
|
51
|
+
selected.value.agentId === node.agent.id;
|
|
52
|
+
|
|
53
|
+
function activate(node: TreeNode) {
|
|
54
|
+
focusKey.value = node.key;
|
|
55
|
+
if (node.type === "device") return toggle(node.key);
|
|
56
|
+
if (node.type === "agent")
|
|
57
|
+
return select({ deviceId: node.deviceId, sessionId: node.sessionId, agentId: node.agent.id });
|
|
58
|
+
const root = node.session.agents.find((a) => a.kind === "root") ?? node.session.agents[0];
|
|
59
|
+
toggle(node.key, true);
|
|
60
|
+
if (root) select({ deviceId: node.deviceId, sessionId: node.session.id, agentId: root.id });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function action(node: TreeNode, name: string) {
|
|
64
|
+
const device = deviceOf(node);
|
|
65
|
+
if (!device) return;
|
|
66
|
+
const client = deviceClient(device.id);
|
|
67
|
+
if (node.type === "device") {
|
|
68
|
+
if (name === "new-session") ui.dialog = { kind: "new-session", deviceId: device.id };
|
|
69
|
+
else if (name === "check") await attempt(() => hub.check(device.id));
|
|
70
|
+
else if (name === "credentials") ui.dialog = { kind: "credentials", deviceId: device.id };
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (node.type === "session") {
|
|
74
|
+
const s = node.session;
|
|
75
|
+
if (name === "new-agent") {
|
|
76
|
+
const created = await attempt(() => client.session(s.id).createAgent({}));
|
|
77
|
+
if (created) {
|
|
78
|
+
select({ deviceId: device.id, sessionId: s.id, agentId: created.id });
|
|
79
|
+
notify("New agent. Same session, same shared App State.");
|
|
80
|
+
}
|
|
81
|
+
} else if (name === "rename")
|
|
82
|
+
ui.dialog = {
|
|
83
|
+
kind: "prompt",
|
|
84
|
+
title: "Rename session",
|
|
85
|
+
label: "Session name",
|
|
86
|
+
value: s.name,
|
|
87
|
+
submit: "Save name",
|
|
88
|
+
run: (value) => client.session(s.id).rename(value),
|
|
89
|
+
};
|
|
90
|
+
else if (name === "archive")
|
|
91
|
+
ui.dialog = {
|
|
92
|
+
kind: "prompt",
|
|
93
|
+
title: `Archive ${s.name}?`,
|
|
94
|
+
hint: `Stops all of its work and moves its files to archive/ on ${device.id}. Nothing is deleted.`,
|
|
95
|
+
submit: "Archive session",
|
|
96
|
+
danger: true,
|
|
97
|
+
run: async () => {
|
|
98
|
+
await client.session(s.id).archive();
|
|
99
|
+
if (selected.value?.sessionId === s.id) select(undefined);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const a = node.agent;
|
|
105
|
+
if (name === "rename")
|
|
106
|
+
ui.dialog = {
|
|
107
|
+
kind: "prompt",
|
|
108
|
+
title: "Rename agent",
|
|
109
|
+
label: "Agent name",
|
|
110
|
+
value: a.named ? a.name : "",
|
|
111
|
+
placeholder: a.name,
|
|
112
|
+
submit: "Save name",
|
|
113
|
+
run: (value) => client.agent(node.sessionId, a.id).rename(value),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function move(delta: number) {
|
|
118
|
+
const i = rows.value.findIndex((r) => r.node.key === focusKey.value);
|
|
119
|
+
const next = rows.value[Math.max(0, Math.min(rows.value.length - 1, (i < 0 ? 0 : i) + delta))];
|
|
120
|
+
if (!next) return;
|
|
121
|
+
focusKey.value = next.node.key;
|
|
122
|
+
void nextTick(() =>
|
|
123
|
+
list.value
|
|
124
|
+
?.querySelector(`[data-key="${CSS.escape(next.node.key)}"]`)
|
|
125
|
+
?.scrollIntoView({ block: "nearest" }),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function focusFirst() {
|
|
130
|
+
focusKey.value ??= rows.value[0]?.node.key;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function keydown(e: KeyboardEvent) {
|
|
134
|
+
const row = rows.value.find((r) => r.node.key === focusKey.value);
|
|
135
|
+
if (e.key === "ArrowDown") move(1);
|
|
136
|
+
else if (e.key === "ArrowUp") move(-1);
|
|
137
|
+
else if (e.key === "ArrowRight" && row) {
|
|
138
|
+
if (!row.node.children.length) return;
|
|
139
|
+
if (expanded.value.has(row.node.key)) move(1);
|
|
140
|
+
else toggle(row.node.key, true);
|
|
141
|
+
} else if (e.key === "ArrowLeft" && row) {
|
|
142
|
+
if (row.node.children.length && expanded.value.has(row.node.key)) toggle(row.node.key, false);
|
|
143
|
+
else if (row.parent) focusKey.value = row.parent;
|
|
144
|
+
} else if ((e.key === "Enter" || e.key === " ") && row) activate(row.node);
|
|
145
|
+
else return;
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
}
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<template>
|
|
151
|
+
<aside
|
|
152
|
+
class="network-sidebar"
|
|
153
|
+
:class="{ open: ui.drawer, collapsed: !layout.tree && !ui.drawer }"
|
|
154
|
+
aria-label="Network"
|
|
155
|
+
>
|
|
156
|
+
<template v-if="layout.tree || ui.drawer">
|
|
157
|
+
<header class="tree-head">
|
|
158
|
+
<span><Network :size="16" />NETWORK</span>
|
|
159
|
+
<button
|
|
160
|
+
class="icon-button"
|
|
161
|
+
aria-label="Pair a device"
|
|
162
|
+
title="Pair a device"
|
|
163
|
+
@click="ui.dialog = { kind: 'pair' }"
|
|
164
|
+
>
|
|
165
|
+
<Link2 :size="15" />
|
|
166
|
+
</button>
|
|
167
|
+
<button
|
|
168
|
+
class="icon-button panel-toggle"
|
|
169
|
+
aria-label="Collapse network"
|
|
170
|
+
:title="`Collapse network (${panelKeys.tree})`"
|
|
171
|
+
@click="((layout.tree = false), (ui.drawer = false))"
|
|
172
|
+
>
|
|
173
|
+
<PanelLeftClose :size="18" />
|
|
174
|
+
</button>
|
|
175
|
+
</header>
|
|
176
|
+
<div
|
|
177
|
+
ref="list"
|
|
178
|
+
class="tree-list"
|
|
179
|
+
role="tree"
|
|
180
|
+
tabindex="0"
|
|
181
|
+
aria-label="Devices, sessions and agents"
|
|
182
|
+
@keydown="keydown"
|
|
183
|
+
@focus="focusFirst"
|
|
184
|
+
>
|
|
185
|
+
<TreeRow
|
|
186
|
+
v-for="row in rows"
|
|
187
|
+
:key="row.node.key"
|
|
188
|
+
:node="row.node"
|
|
189
|
+
:depth="row.depth"
|
|
190
|
+
:open="expanded.has(row.node.key)"
|
|
191
|
+
:selected="isSelected(row.node)"
|
|
192
|
+
:focused="focusKey === row.node.key"
|
|
193
|
+
:online="isOnline(row.node)"
|
|
194
|
+
@toggle="toggle(row.node.key)"
|
|
195
|
+
@activate="activate(row.node)"
|
|
196
|
+
@action="action(row.node, $event)"
|
|
197
|
+
/>
|
|
198
|
+
<p v-if="!rows.length" class="hint tree-empty" data-testid="tree-empty">
|
|
199
|
+
No devices yet. Declare them in <code>dpi-network.yaml</code> (start from
|
|
200
|
+
<code>dpi-network.example.yaml</code>), then run <code>just net up <id></code>.
|
|
201
|
+
</p>
|
|
202
|
+
</div>
|
|
203
|
+
</template>
|
|
204
|
+
<div v-else class="panel-rail" aria-label="Network (collapsed)">
|
|
205
|
+
<button
|
|
206
|
+
class="icon-button panel-toggle"
|
|
207
|
+
aria-label="Expand network"
|
|
208
|
+
:title="`Expand network (${panelKeys.tree})`"
|
|
209
|
+
@click="layout.tree = true"
|
|
210
|
+
>
|
|
211
|
+
<PanelLeftOpen :size="18" />
|
|
212
|
+
</button>
|
|
213
|
+
<button
|
|
214
|
+
v-for="d in network.devices.value"
|
|
215
|
+
:key="d.id"
|
|
216
|
+
class="rail-device"
|
|
217
|
+
:title="`${d.id} · ${d.status}`"
|
|
218
|
+
:aria-label="`Show ${d.id}`"
|
|
219
|
+
@click="((layout.tree = true), toggle(d.id, true))"
|
|
220
|
+
>
|
|
221
|
+
<DeviceIcon :device="d" :size="20" /><i
|
|
222
|
+
class="dot"
|
|
223
|
+
:class="
|
|
224
|
+
d.status === 'online'
|
|
225
|
+
? 'on'
|
|
226
|
+
: d.status === 'checking'
|
|
227
|
+
? 'chk'
|
|
228
|
+
: d.status === 'offline'
|
|
229
|
+
? 'off'
|
|
230
|
+
: 'err'
|
|
231
|
+
"
|
|
232
|
+
/>
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
</aside>
|
|
236
|
+
</template>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
|
+
import { Plus, X } from "lucide-vue-next";
|
|
4
|
+
import DirBrowser from "./DirBrowser.vue";
|
|
5
|
+
import { deviceClient, message } from "../client/hub";
|
|
6
|
+
import { network, notify, select } from "../client/store";
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{ deviceId?: string }>();
|
|
9
|
+
const emit = defineEmits<{ close: [] }>();
|
|
10
|
+
|
|
11
|
+
const devices = computed(() => network.devices.value.filter((d) => d.status === "online"));
|
|
12
|
+
const deviceId = ref(
|
|
13
|
+
devices.value.find((d) => d.id === props.deviceId)?.id ?? devices.value[0]?.id ?? "",
|
|
14
|
+
);
|
|
15
|
+
const device = computed(() => devices.value.find((d) => d.id === deviceId.value));
|
|
16
|
+
const models = computed(() => device.value?.info?.models ?? []);
|
|
17
|
+
const key = (m: { provider: string; id: string }) => `${m.provider}/${m.id}`;
|
|
18
|
+
const draft = ref({ name: "", cwd: "", model: "" });
|
|
19
|
+
const pending = ref(false);
|
|
20
|
+
const error = ref("");
|
|
21
|
+
|
|
22
|
+
watch(
|
|
23
|
+
device,
|
|
24
|
+
(d) => {
|
|
25
|
+
const fallback =
|
|
26
|
+
d?.info?.models.find((m) => m.id === d.info?.defaultModel) ?? d?.info?.models[0];
|
|
27
|
+
draft.value.model = fallback ? key(fallback) : "";
|
|
28
|
+
draft.value.cwd = "";
|
|
29
|
+
},
|
|
30
|
+
{ immediate: true },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
async function create() {
|
|
34
|
+
if (pending.value || !device.value) return;
|
|
35
|
+
pending.value = true;
|
|
36
|
+
error.value = "";
|
|
37
|
+
try {
|
|
38
|
+
const model = models.value.find((m) => key(m) === draft.value.model);
|
|
39
|
+
if (!model) throw Error("Choose a model");
|
|
40
|
+
const created = await deviceClient(device.value.id).createSession({
|
|
41
|
+
name: draft.value.name.trim() || "New session",
|
|
42
|
+
cwd: draft.value.cwd.trim(),
|
|
43
|
+
model: { provider: model.provider, modelId: model.id },
|
|
44
|
+
});
|
|
45
|
+
const root = created.agents.find((a) => a.kind === "root") ?? created.agents[0];
|
|
46
|
+
select({ deviceId: device.value.id, sessionId: created.id, agentId: root?.id ?? 1 });
|
|
47
|
+
notify(`Session created on ${device.value.id}.`);
|
|
48
|
+
emit("close");
|
|
49
|
+
} catch (e) {
|
|
50
|
+
error.value = message(e);
|
|
51
|
+
} finally {
|
|
52
|
+
pending.value = false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<div class="modal-backdrop" @click.self="emit('close')">
|
|
59
|
+
<form class="modal session-modal" @submit.prevent="create">
|
|
60
|
+
<header>
|
|
61
|
+
<div>
|
|
62
|
+
<span class="eyebrow">A PERSISTENT WORKSPACE</span>
|
|
63
|
+
<h2>New session</h2>
|
|
64
|
+
</div>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
class="icon-button"
|
|
68
|
+
aria-label="Close new session"
|
|
69
|
+
@click="emit('close')"
|
|
70
|
+
>
|
|
71
|
+
<X :size="18" />
|
|
72
|
+
</button>
|
|
73
|
+
</header>
|
|
74
|
+
<p v-if="!devices.length" class="message-error">
|
|
75
|
+
No device is online. Start one with just backend run or just net up <id>, then try
|
|
76
|
+
again.
|
|
77
|
+
</p>
|
|
78
|
+
<template v-else>
|
|
79
|
+
<label
|
|
80
|
+
>Device<select v-model="deviceId" aria-label="Device">
|
|
81
|
+
<option v-for="d in devices" :key="d.id" :value="d.id">
|
|
82
|
+
{{ d.id }} · {{ d.info?.hostname ?? d.url }}
|
|
83
|
+
</option>
|
|
84
|
+
</select></label
|
|
85
|
+
>
|
|
86
|
+
<label
|
|
87
|
+
>Session name<input
|
|
88
|
+
v-model="draft.name"
|
|
89
|
+
autofocus
|
|
90
|
+
placeholder="My durable agent"
|
|
91
|
+
maxlength="100"
|
|
92
|
+
/></label>
|
|
93
|
+
<div class="field">
|
|
94
|
+
<span>Working directory on {{ deviceId }}</span>
|
|
95
|
+
<DirBrowser v-model="draft.cwd" :device-id="deviceId" />
|
|
96
|
+
</div>
|
|
97
|
+
<label
|
|
98
|
+
>Model<select v-model="draft.model" aria-label="New session model">
|
|
99
|
+
<option v-for="m in models" :key="key(m)" :value="key(m)">
|
|
100
|
+
{{ m.tier }} / {{ m.name }}
|
|
101
|
+
</option>
|
|
102
|
+
</select></label
|
|
103
|
+
>
|
|
104
|
+
</template>
|
|
105
|
+
<p v-if="error" class="message-error" role="alert">{{ error }}</p>
|
|
106
|
+
<footer>
|
|
107
|
+
<button type="button" class="secondary" @click="emit('close')">Cancel</button
|
|
108
|
+
><button class="primary" :disabled="pending || !draft.model || !draft.cwd || !device">
|
|
109
|
+
<Plus :size="18" />{{ pending ? "Creating…" : "Create session" }}
|
|
110
|
+
</button>
|
|
111
|
+
</footer>
|
|
112
|
+
</form>
|
|
113
|
+
</div>
|
|
114
|
+
</template>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { Copy, RefreshCw, ShieldAlert, WifiOff, Loader } from "lucide-vue-next";
|
|
4
|
+
import type { NetworkDevice } from "@sdk/api";
|
|
5
|
+
import { hub, message } from "../client/hub";
|
|
6
|
+
import { ago, notify } from "../client/store";
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{ device: NetworkDevice }>();
|
|
9
|
+
const busy = ref(false);
|
|
10
|
+
const error = ref("");
|
|
11
|
+
|
|
12
|
+
/** The exact fix for this device's state. Device setup happens in the terminal, never in this UI. */
|
|
13
|
+
const fix = computed(() => {
|
|
14
|
+
const d = props.device;
|
|
15
|
+
const config = d.source === "config";
|
|
16
|
+
switch (d.status) {
|
|
17
|
+
case "checking":
|
|
18
|
+
return {
|
|
19
|
+
title: `Checking ${d.id}…`,
|
|
20
|
+
text: "Probing the device. This view fills in as soon as it answers.",
|
|
21
|
+
};
|
|
22
|
+
case "unauthorized":
|
|
23
|
+
if (d.transport === "tunnel") {
|
|
24
|
+
// The hub never parks tunnel devices on unauthorized; if it happens,
|
|
25
|
+
// point at pairing, not at a token the browser does not hold.
|
|
26
|
+
return {
|
|
27
|
+
title: `${d.id} tunnel credentials are not valid`,
|
|
28
|
+
text: "Pair the device again — the hub will mint a fresh credential.",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
title: d.hasToken ? `${d.id} rejected its token` : `No token for ${d.id}`,
|
|
33
|
+
text: d.hasToken
|
|
34
|
+
? "The device's DPI_TOKEN changed (a rotate or a rebuilt device). Copy the current token into this hub's registry, then check again."
|
|
35
|
+
: "This hub has no token for the device yet. Register it, then check again.",
|
|
36
|
+
command:
|
|
37
|
+
config && d.kind !== "url"
|
|
38
|
+
? `just net register ${d.id}`
|
|
39
|
+
: `just net register ${d.id} --token <token>`,
|
|
40
|
+
};
|
|
41
|
+
case "mismatch":
|
|
42
|
+
return {
|
|
43
|
+
title: `${d.id} answers as a different device`,
|
|
44
|
+
text: "The URL now reaches a backend with another identity (a rebuilt VM or a new data directory). If that is expected, register it again: that trusts the identity it answers with now.",
|
|
45
|
+
command:
|
|
46
|
+
d.kind === "url"
|
|
47
|
+
? `just net register ${d.id} --token <token>`
|
|
48
|
+
: `just net register ${d.id}`,
|
|
49
|
+
};
|
|
50
|
+
case "incompatible":
|
|
51
|
+
return {
|
|
52
|
+
title: `${d.id} speaks another API version`,
|
|
53
|
+
text: "Update the device's dpi-backend.",
|
|
54
|
+
command: config ? `just net deploy ${d.id}` : undefined,
|
|
55
|
+
};
|
|
56
|
+
default:
|
|
57
|
+
return {
|
|
58
|
+
title: `${d.id} is offline`,
|
|
59
|
+
text: `Last seen ${ago(d.lastSeenAt)}. Its agents keep their state on the device and resume when its backend starts again. The tree shows the last known sessions.`,
|
|
60
|
+
command: !config
|
|
61
|
+
? undefined
|
|
62
|
+
: d.kind === "local"
|
|
63
|
+
? "just backend run"
|
|
64
|
+
: d.kind === "url"
|
|
65
|
+
? undefined
|
|
66
|
+
: `just net restart ${d.id}`,
|
|
67
|
+
alt: config && d.kind !== "local" && d.kind !== "url" ? `just net up ${d.id}` : undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async function check() {
|
|
73
|
+
busy.value = true;
|
|
74
|
+
error.value = "";
|
|
75
|
+
try {
|
|
76
|
+
const d = await hub.check(props.device.id);
|
|
77
|
+
if (d.status === "online") notify(`${d.id} is online.`);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
error.value = message(e);
|
|
80
|
+
} finally {
|
|
81
|
+
busy.value = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function copy(text: string) {
|
|
85
|
+
try {
|
|
86
|
+
await navigator.clipboard.writeText(text);
|
|
87
|
+
notify("Copied.");
|
|
88
|
+
} catch {
|
|
89
|
+
notify("Clipboard needs localhost or HTTPS. Select the text to copy.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<section class="chat-panel offline-panel" :class="device.status" aria-live="polite">
|
|
96
|
+
<div class="chat-empty">
|
|
97
|
+
<Loader v-if="device.status === 'checking'" class="spin" :size="30" />
|
|
98
|
+
<ShieldAlert
|
|
99
|
+
v-else-if="device.status === 'mismatch' || device.status === 'unauthorized'"
|
|
100
|
+
:size="30"
|
|
101
|
+
/>
|
|
102
|
+
<WifiOff v-else :size="30" />
|
|
103
|
+
<span class="eyebrow"
|
|
104
|
+
>{{ device.kind.toUpperCase() }} ·
|
|
105
|
+
{{ device.url ?? (device.transport === "tunnel" ? "tunnel" : "no endpoint") }}</span
|
|
106
|
+
>
|
|
107
|
+
<h2>{{ fix.title }}</h2>
|
|
108
|
+
<p>{{ fix.text }}</p>
|
|
109
|
+
<p v-if="device.error" class="hint offline-error">{{ device.error }}</p>
|
|
110
|
+
<div v-if="fix.command" class="offline-command">
|
|
111
|
+
<code>{{ fix.command }}</code>
|
|
112
|
+
<button class="icon-button" aria-label="Copy command" @click="copy(fix.command)">
|
|
113
|
+
<Copy :size="18" />
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
<p v-if="fix.command" class="hint">Run it in this repo's terminal, then press Check now.</p>
|
|
117
|
+
<p v-if="fix.alt" class="hint">
|
|
118
|
+
Never deployed or rebuilt? <code>{{ fix.alt }}</code> provisions it from scratch.
|
|
119
|
+
</p>
|
|
120
|
+
<div class="offline-actions">
|
|
121
|
+
<button class="secondary" :disabled="busy" @click="check()">
|
|
122
|
+
<RefreshCw :size="18" />{{ busy ? "Checking…" : "Check now" }}
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
<p v-if="error" class="message-error" role="alert">{{ error }}</p>
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
</template>
|